diff --git a/.github/actions/cora-review-simple/action.yml b/.github/actions/cora-review-simple/action.yml new file mode 100644 index 00000000..c217dcb6 --- /dev/null +++ b/.github/actions/cora-review-simple/action.yml @@ -0,0 +1,231 @@ +name: 'Cora AI Code Review' +description: 'Run cora AI code review on a PR diff โ€” posts a PR comment. Uses GitHub Secrets directly (no Infisical required). SARIF upload is optional.' + +inputs: + base-branch: + description: 'Base branch to compare against (default: origin/develop)' + required: false + default: 'origin/develop' + severity: + description: 'Minimum severity to report (info, minor, major, critical)' + required: false + default: 'major' + cora-version: + description: 'cora-cli version tag (default: latest release)' + required: false + default: 'latest' + upload-sarif: + description: 'Upload SARIF to GitHub Code Scanning (default: false)' + required: false + default: 'false' + cora-api-key: + description: 'LLM API key (from secrets.CORA_API_KEY)' + required: true + cora-base-url: + description: 'LLM API base URL (from secrets.CORA_BASE_URL)' + required: true + cora-model: + description: 'LLM model ID (from secrets.CORA_MODEL)' + required: true + github-token: + description: 'GitHub token for PR comments and SARIF upload' + required: true + +runs: + using: 'composite' + steps: + - name: Resolve cora-cli version + id: resolve + shell: bash + run: | + if [ "${{ inputs.cora-version }}" = "latest" ]; then + # Try up to 3 times with delay + for i in 1 2 3; do + VERSION=$(curl -sfL --connect-timeout 10 \ + https://api.github.com/repos/codecoradev/cora-cli/releases/latest \ + | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('tag_name', ''))" 2>/dev/null) + if [ -n "$VERSION" ]; then + break + fi + echo "Attempt $i failed, retrying..." + sleep 5 + done + # Fallback to a known good version if all retries fail + if [ -z "$VERSION" ]; then + echo "::warning::Failed to resolve latest cora-cli version from GitHub API, using fallback" + VERSION="v0.1.6" + fi + else + VERSION="${{ inputs.cora-version }}" + fi + echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT" + echo "Resolved cora-cli version: $VERSION" + + - name: Install cora-cli + shell: bash + run: | + ARCH=$(uname -m) + if [ "$ARCH" = "x86_64" ]; then + ASSET="cora-x86_64-unknown-linux-gnu" + elif [ "$ARCH" = "aarch64" ]; then + ASSET="cora-aarch64-unknown-linux-gnu" + else + echo "Unsupported architecture: $ARCH" + exit 1 + fi + curl -sL "https://github.com/codecoradev/cora-cli/releases/download/${{ steps.resolve.outputs.VERSION }}/${ASSET}-${{ steps.resolve.outputs.VERSION }}.tar.gz" | tar xz -C /usr/local/bin cora + chmod +x /usr/local/bin/cora + cora --version + + - name: Run cora review + id: review + shell: bash + env: + CORA_API_KEY: ${{ inputs.cora-api-key }} + CORA_BASE_URL: ${{ inputs.cora-base-url }} + CORA_MODEL: ${{ inputs.cora-model }} + run: | + cora review \ + --base ${{ inputs.base-branch }} \ + --format sarif \ + --severity ${{ inputs.severity }} \ + --quiet \ + > cora-results.sarif 2>/dev/null + echo "Cora review complete ($(wc -c < cora-results.sarif) bytes)" + + - name: Check SARIF for findings + id: sarif-check + if: inputs.upload-sarif == 'true' + shell: bash + run: | + RESULTS=$(python3 -c " + import json, sys + try: + data = json.load(open('cora-results.sarif')) + results = data.get('runs', [{}])[0].get('results', []) + print(len(results)) + except: + print(0) + " 2>/dev/null || echo "0") + echo "results=$RESULTS" >> "$GITHUB_OUTPUT" + if [ "$RESULTS" -gt 0 ]; then + echo "SARIF contains $RESULTS finding(s) โ€” will upload." + else + echo "SARIF has no findings โ€” skipping upload." + fi + + - name: Upload SARIF to GitHub Code Scanning + if: inputs.upload-sarif == 'true' && steps.sarif-check.outputs.results > 0 + continue-on-error: true + uses: github/codeql-action/upload-sarif@v4 + with: + sarif_file: cora-results.sarif + category: cora-review + + - name: Post PR comment + if: always() && github.event_name == 'pull_request' + uses: actions/github-script@v7 + env: + GH_TOKEN: ${{ inputs.github-token }} + with: + script: | + const fs = require('fs'); + + let sarifContent; + try { + sarifContent = JSON.parse(fs.readFileSync('cora-results.sarif', 'utf8')); + } catch (e) { + sarifContent = null; + } + + let body; + if (!sarifContent || !sarifContent.runs || sarifContent.runs.length === 0) { + body = `## ๐Ÿ” Cora AI Code Review\n\nโœ… **No issues found.** Code looks good!`; + } else { + const results = sarifContent.runs[0].results || []; + if (results.length === 0) { + body = `## ๐Ÿ” Cora AI Code Review\n\nโœ… **No issues found.** Code looks good!`; + } else { + const grouped = {}; + for (const r of results) { + const sev = (r.level || 'note').charAt(0).toUpperCase() + (r.level || 'note').slice(1); + if (!grouped[sev]) grouped[sev] = []; + grouped[sev].push(r); + } + + const severityOrder = ['Error', 'Warning', 'Note']; + let table = ''; + + for (const sev of severityOrder) { + if (!grouped[sev]) continue; + const icon = sev === 'Error' ? '๐Ÿ”ด' : sev === 'Warning' ? '๐ŸŸก' : '๐Ÿ”ต'; + table += `\n### ${icon} ${sev} (${grouped[sev].length})\n\n`; + for (const r of grouped[sev]) { + const loc = r.locations?.[0]?.physicalLocation; + const file = loc?.artifactLocation?.uri || 'unknown'; + const line = loc?.region?.startLine || '?'; + const msg = r.message?.text || r.message?.markdown || 'No message'; + table += `- \`${file}:${line}\` โ€” ${msg}\n`; + } + } + + const hasErrors = (grouped['Error'] || []).length > 0; + const hasWarnings = (grouped['Warning'] || []).length > 0; + const status = hasErrors ? 'โŒ **Blocked** โ€” critical issues found.' : + hasWarnings ? 'โš ๏ธ **Review recommended** โ€” warnings found.' : + 'โœ… **Passed** โ€” only informational notes.'; + + body = `## ๐Ÿ” Cora AI Code Review\n\n${status}\n${table}\n---\n_Review powered by [cora-cli](https://github.com/codecoradev/cora-cli) ยท BYOK ยท MIT_`; + } + } + + // Find existing cora comment and update it, or create new + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const existing = comments.find(c => + c.user.login === 'github-actions[bot]' && + c.body.startsWith('## ๐Ÿ” Cora AI Code Review') + ); + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + } + + - name: Check for blocking issues + if: always() + shell: bash + run: | + if [ -f cora-results.sarif ]; then + ERRORS=$(python3 -c " + import json, sys + try: + data = json.load(open('cora-results.sarif')) + results = data.get('runs', [{}])[0].get('results', []) + errors = [r for r in results if r.get('level') == 'error'] + print(len(errors)) + except: + print(0) + " 2>/dev/null || echo "0") + + if [ "$ERRORS" -gt 0 ]; then + echo "::error::Cora found $ERRORS blocking issue(s)." + exit 1 + fi + fi + echo "No blocking issues." diff --git a/.github/actions/cora-review/action.yml b/.github/actions/cora-review/action.yml new file mode 100644 index 00000000..30ac653a --- /dev/null +++ b/.github/actions/cora-review/action.yml @@ -0,0 +1,275 @@ +name: 'Cora AI Code Review' +description: 'Run cora AI code review on a PR diff. Posts PR comment with findings.' + +inputs: + base-branch: + description: 'Base branch to compare against (default: origin/develop)' + required: false + default: 'origin/develop' + severity: + description: 'Minimum severity to report (info, minor, major, critical)' + required: false + default: 'major' + cora-version: + description: 'cora-cli version tag (default: latest release)' + required: false + default: 'latest' + upload-sarif: + description: 'Upload SARIF to GitHub Code Scanning (default: true). Set to "false" to disable.' + required: false + default: 'true' + cora-api-key: + description: 'LLM API key' + required: true + cora-base-url: + description: 'LLM API base URL' + required: false + cora-model: + description: 'LLM model ID' + required: false + github-token: + description: 'GitHub token for PR comments and SARIF upload' + required: true + +runs: + using: 'composite' + steps: + - name: Resolve cora-cli version + id: resolve + shell: bash + env: + INPUT_VERSION: ${{ inputs.cora-version }} + run: | + if [ "$INPUT_VERSION" = "latest" ]; then + for i in 1 2 3; do + VERSION=$(curl -sfL --connect-timeout 10 \ + https://api.github.com/repos/codecoradev/cora-cli/releases/latest \ + | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('tag_name', ''))" 2>/dev/null) + if [ -n "$VERSION" ]; then + break + fi + echo "Attempt $i failed, retrying..." + sleep 5 + done + if [ -z "$VERSION" ]; then + echo "::warning::Failed to resolve latest cora-cli version from GitHub API, using fallback" + VERSION="v0.3.0" + fi + else + VERSION="$INPUT_VERSION" + fi + echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT" + echo "Resolved cora-cli version: $VERSION" + + - name: Install cora-cli + shell: bash + env: + CORA_VERSION: ${{ steps.resolve.outputs.VERSION }} + run: | + ARCH=$(uname -m) + if [ "$ARCH" = "x86_64" ]; then + ASSET="cora-x86_64-unknown-linux-gnu" + elif [ "$ARCH" = "aarch64" ]; then + ASSET="cora-aarch64-unknown-linux-gnu" + else + echo "Unsupported architecture: $ARCH" + exit 1 + fi + curl -sL "https://github.com/codecoradev/cora-cli/releases/download/${CORA_VERSION}/${ASSET}-${CORA_VERSION}.tar.gz" \ + -o /tmp/cora.tar.gz + curl -sL "https://github.com/codecoradev/cora-cli/releases/download/${CORA_VERSION}/checksums-sha256.txt" \ + -o /tmp/cora-checksums.txt || true + if [ -f /tmp/cora-checksums.txt ] && [ -s /tmp/cora-checksums.txt ]; then + EXPECTED=$(grep "${ASSET}-${CORA_VERSION}.tar.gz" /tmp/cora-checksums.txt | awk '{print $1}' || true) + ACTUAL=$(sha256sum /tmp/cora.tar.gz | awk '{print $1}') + if [ -n "$EXPECTED" ] && [ "$EXPECTED" != "$ACTUAL" ]; then + echo "::error::Checksum verification failed for ${ASSET}-${CORA_VERSION}.tar.gz" + echo "Expected: $EXPECTED" + echo "Actual: $ACTUAL" + exit 1 + elif [ -z "$EXPECTED" ]; then + echo "::warning::No checksum found for ${ASSET}-${CORA_VERSION}.tar.gz in checksums file" + fi + rm -f /tmp/cora-checksums.txt + else + echo "::warning::No checksums file found, skipping verification" + fi + tar xzf /tmp/cora.tar.gz -C /usr/local/bin cora + rm -f /tmp/cora.tar.gz + chmod +x /usr/local/bin/cora + cora --version + + - name: Run cora review + id: review + shell: bash + env: + CORA_API_KEY: ${{ inputs.cora-api-key }} + CORA_BASE_URL: ${{ inputs.cora-base-url }} + CORA_MODEL: ${{ inputs.cora-model }} + INPUT_BASE: ${{ inputs.base-branch }} + INPUT_SEVERITY: ${{ inputs.severity }} + run: | + # Set higher diff limit for CI (default 50K is too small for large PRs) + printf 'hook:\n max_diff_size: 200000\n' > .cora-ci.yaml + CORA_CONFIG=".cora-ci.yaml" cora review \ + --base "$INPUT_BASE" \ + --format sarif \ + --severity "$INPUT_SEVERITY" \ + --quiet \ + > cora-results.sarif 2>cora-stderr.log; EXIT_CODE=$? + SARIF_BYTES=$(wc -c < cora-results.sarif) + echo "Cora review complete ($SARIF_BYTES bytes, exit=$EXIT_CODE)" + if [ -s cora-stderr.log ]; then + echo "::warning::Cora stderr: $(cat cora-stderr.log)" + fi + # Fail if cora exited non-zero AND produced empty result (LLM API failure) + if [ "$EXIT_CODE" -ne 0 ] && [ "$SARIF_BYTES" -lt 10 ]; then + echo "::error::Cora review failed (exit=$EXIT_CODE, $SARIF_BYTES bytes). LLM API may be unavailable." + exit 1 + fi + + - name: Check SARIF for findings + id: sarif-check + if: inputs.upload-sarif == 'true' + shell: bash + run: | + RESULTS=$(python3 -c " + import json, sys + try: + data = json.load(open('cora-results.sarif')) + results = data.get('runs', [{}])[0].get('results', []) + print(len(results)) + except: + print(0) + " 2>/dev/null || echo "0") + echo "results=$RESULTS" >> "$GITHUB_OUTPUT" + if [ "$RESULTS" -gt 0 ]; then + echo "SARIF contains $RESULTS finding(s) โ€” will upload." + else + echo "SARIF has no findings โ€” skipping upload." + fi + + - name: Upload SARIF to GitHub Code Scanning + if: inputs.upload-sarif == 'true' && steps.sarif-check.outputs.results > 0 + continue-on-error: true + uses: github/codeql-action/upload-sarif@f52b05f4acaaa234e44466e66d29050e135ea9ef # v4.36.0 + with: + sarif_file: cora-results.sarif + category: cora-review + + - name: Post PR comment + if: always() && github.event_name == 'pull_request' + uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9.0.0 + env: + GH_TOKEN: ${{ inputs.github-token }} + with: + script: | + const fs = require('fs'); + + let sarifContent; + try { + sarifContent = JSON.parse(fs.readFileSync('cora-results.sarif', 'utf8')); + } catch (e) { + sarifContent = null; + } + + let body; + if (!sarifContent || !sarifContent.runs || sarifContent.runs.length === 0) { + let fileSize = 0; + try { + fileSize = fs.statSync('cora-results.sarif').size; + } catch (e) { + fileSize = 0; + } + if (fileSize < 10) { + body = `## ๐Ÿ” Cora AI Code Review\n\nโš ๏ธ **Review could not complete.** Cora produced an empty result. Check the workflow logs for errors.\n\n---\n_Review powered by [cora-cli](https://github.com/codecoradev/cora-cli) ยท BYOK ยท MIT_`; + } else { + body = `## ๐Ÿ” Cora AI Code Review\n\nโœ… **No issues found.** Code looks good!\n\n---\n_Review powered by [cora-cli](https://github.com/codecoradev/cora-cli) ยท BYOK ยท MIT_`; + } + } else { + const results = sarifContent.runs[0].results || []; + if (results.length === 0) { + body = `## ๐Ÿ” Cora AI Code Review\n\nโœ… **No issues found.** Code looks good!\n\n---\n_Review powered by [cora-cli](https://github.com/codecoradev/cora-cli) ยท BYOK ยท MIT_`; + } else { + const grouped = {}; + for (const r of results) { + const sev = (r.level || 'note').charAt(0).toUpperCase() + (r.level || 'note').slice(1); + if (!grouped[sev]) grouped[sev] = []; + grouped[sev].push(r); + } + + const severityOrder = ['Error', 'Warning', 'Note']; + let table = ''; + + for (const sev of severityOrder) { + if (!grouped[sev]) continue; + const icon = sev === 'Error' ? '๐Ÿ”ด' : sev === 'Warning' ? '๐ŸŸก' : '๐Ÿ”ต'; + table += `\n### ${icon} ${sev} (${grouped[sev].length})\n\n`; + for (const r of grouped[sev]) { + const loc = r.locations?.[0]?.physicalLocation; + const file = loc?.artifactLocation?.uri || 'unknown'; + const line = loc?.region?.startLine || '?'; + const msg = r.message?.text || r.message?.markdown || 'No message'; + table += `- \`${file}:${line}\` โ€” ${msg}\n`; + } + } + + const hasErrors = (grouped['Error'] || []).length > 0; + const hasWarnings = (grouped['Warning'] || []).length > 0; + const status = hasErrors ? 'โŒ **Blocked** โ€” critical issues found.' : + hasWarnings ? 'โš ๏ธ **Review recommended** โ€” warnings found.' : + 'โœ… **Passed** โ€” only informational notes.'; + + body = `## ๐Ÿ” Cora AI Code Review\n\n${status}\n${table}\n---\n_Review powered by [cora-cli](https://github.com/codecoradev/cora-cli) ยท BYOK ยท MIT_`; + } + } + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const existing = comments.find(c => + c.user.login === 'github-actions[bot]' && + c.body.startsWith('## ๐Ÿ” Cora AI Code Review') + ); + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + } + + - name: Check for blocking issues + if: always() + shell: bash + run: | + if [ -f cora-results.sarif ]; then + ERRORS=$(python3 -c " + import json, sys + try: + data = json.load(open('cora-results.sarif')) + results = data.get('runs', [{}])[0].get('results', []) + errors = [r for r in results if r.get('level') == 'error'] + print(len(errors)) + except: + print(0) + " 2>/dev/null || echo "0") + + if [ "$ERRORS" -gt 0 ]; then + echo "::error::Cora found $ERRORS blocking issue(s)." + exit 1 + fi + fi + echo "No blocking issues." diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 91fc88f0..81c14327 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,88 +1,78 @@ name: CI on: - push: - branches: [main] pull_request: + branches: [develop] + push: branches: [main] env: CARGO_TERM_COLOR: always RUST_BACKTRACE: 1 + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +permissions: + contents: read + security-events: write + pull-requests: write + id-token: write jobs: - test: - name: Test (${{ matrix.rust }}) + check: + name: Check runs-on: ubuntu-latest - continue-on-error: ${{ matrix.rust == 'nightly' }} - strategy: - fail-fast: false - matrix: - rust: [stable, nightly] steps: - - name: Checkout repository - uses: actions/checkout@v4 + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo check --all-targets - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + fmt: + name: Format + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable with: - toolchain: ${{ matrix.rust }} - components: clippy, rustfmt + components: rustfmt + - run: cargo fmt --all -- --check - - name: Cache cargo registry & build - uses: actions/cache@v4 + clippy: + name: Clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable with: - path: | - ~/.cargo/registry - ~/.cargo/git - target - key: ${{ runner.os }}-cargo-${{ matrix.rust }}-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - ${{ runner.os }}-cargo-${{ matrix.rust }}- - - - name: Check formatting - if: matrix.rust == 'stable' - run: cargo fmt --all -- --check + components: clippy + - uses: Swatinem/rust-cache@v2 + - run: cargo clippy --all-targets -- -D warnings - - name: Run clippy - if: matrix.rust == 'stable' - run: cargo clippy --all-targets --all-features -- -D warnings - - - name: Run tests - run: cargo test --all-features --verbose - - - name: Build release - run: cargo build --release --verbose - - lint: - name: Lint & Format (stable) + audit: + name: Security Audit runs-on: ubuntu-latest steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Install Rust toolchain (stable) - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@v4 + - uses: rustsec/audit-check@858dc40f52ca2b8570b7a997c1c4e35c6fc9a432 with: - components: clippy, rustfmt - - - name: Check formatting - run: cargo fmt --all -- --check + token: ${{ secrets.GITHUB_TOKEN }} - - name: Run clippy - run: cargo clippy --all-targets --all-features -- -D warnings + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo test - msrv: - name: Check MSRV + build: + name: Build runs-on: ubuntu-latest steps: - - name: Checkout repository - uses: actions/checkout@v4 + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo build --release - - name: Install Rust toolchain (MSRV) - uses: dtolnay/rust-toolchain@stable - with: - toolchain: "1.75.0" - - name: Build - run: cargo build diff --git a/.github/workflows/cora-review.yml b/.github/workflows/cora-review.yml new file mode 100644 index 00000000..e4ffcda5 --- /dev/null +++ b/.github/workflows/cora-review.yml @@ -0,0 +1,36 @@ +name: Cora AI Code Review + +on: + pull_request: + branches: [develop, main] + types: [opened, synchronize, ready_for_review, reopened] + +concurrency: + group: cora-review-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write + id-token: write + security-events: write + +jobs: + cora-review: + name: Cora Review + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout repository + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Run Cora AI Code Review + uses: ./.github/actions/cora-review + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + cora-api-key: ${{ secrets.CORA_API_KEY }} + cora-base-url: ${{ secrets.CORA_BASE_URL }} + cora-model: ${{ secrets.CORA_MODEL }} diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml new file mode 100644 index 00000000..a871146e --- /dev/null +++ b/.github/workflows/deploy-website.yml @@ -0,0 +1,55 @@ +name: Deploy Website to Cloudflare Pages + +on: + push: + branches: [develop] + paths: + - 'website/**' + - '.github/workflows/deploy-website.yml' + workflow_dispatch: + +concurrency: + group: deploy-website + cancel-in-progress: false + +permissions: + id-token: write + contents: read + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + cache-dependency-path: website/package-lock.json + + - name: Install dependencies + run: npm ci + working-directory: website + + - name: Build + run: npm run build + working-directory: website + + - name: Verify build output + run: | + if [ ! -d "website/build" ] || [ -z "$(ls -A website/build)" ]; then + echo "::error::Build output is empty or missing." + exit 1 + fi + + - name: Deploy to Cloudflare Pages + uses: cloudflare/wrangler-action@v3 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + preCommands: npx wrangler pages project create cora --production-branch=develop || true + command: pages deploy website/build/ --project-name=cora --commit-dirty=true --branch=develop diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1e788c16..a52ad3ed 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,105 +3,195 @@ name: Release on: push: tags: - - "v*" - -permissions: - contents: write + - 'v*' env: CARGO_TERM_COLOR: always - CARGO_INCREMENTAL: 0 - RUST_BACKTRACE: 1 + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true jobs: + sync-main: + name: Sync main branch + 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 + with: + ref: develop + + - name: Push to main + run: | + git push origin HEAD:main --force + echo "โœ… main synced to develop at ${{ github.sha }}" + build-release: - name: Build (${{ matrix.target }}) - runs-on: ${{ matrix.runner }} + needs: sync-main + name: Build ${{ matrix.target }} + runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: include: - - target: aarch64-unknown-linux-gnu - runner: ubuntu-latest - artifact_name: cora - asset_name: cora-linux-aarch64 - - target: x86_64-unknown-linux-gnu - runner: ubuntu-latest - artifact_name: cora - asset_name: cora-linux-x86_64 - - target: aarch64-apple-darwin - runner: macos-latest - artifact_name: cora - asset_name: cora-macos-aarch64 - - target: x86_64-apple-darwin - runner: macos-latest - artifact_name: cora - asset_name: cora-macos-x86_64 - - target: x86_64-pc-windows-msvc - runner: windows-latest - artifact_name: cora.exe - asset_name: cora-windows-x86_64 + # Linux x86_64 + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + artifact: cora-x86_64-unknown-linux-gnu + ext: tar.gz + # Linux ARM64 + - os: ubuntu-24.04-arm + target: aarch64-unknown-linux-gnu + artifact: cora-aarch64-unknown-linux-gnu + ext: tar.gz + # macOS Apple Silicon + - os: macos-latest + target: aarch64-apple-darwin + artifact: cora-aarch64-apple-darwin + ext: tar.gz + # Windows x86_64 + - os: windows-latest + target: x86_64-pc-windows-msvc + artifact: cora-x86_64-pc-windows-msvc + ext: zip steps: - - name: Checkout repository - uses: actions/checkout@v4 + - uses: actions/checkout@v4 - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@stable with: targets: ${{ matrix.target }} - - name: Install cross-compilation dependencies (Linux ARM64) - if: matrix.target == 'aarch64-unknown-linux-gnu' - run: | - sudo apt-get update - sudo apt-get install -y gcc-aarch64-linux-gnu - echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc" >> $GITHUB_ENV - echo "CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc" >> $GITHUB_ENV + - name: Install Linux dependencies + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y musl-tools - name: Build release binary run: cargo build --release --target ${{ matrix.target }} - - name: Package binary + - name: Strip binary (Unix) + if: runner.os != 'Windows' + run: strip target/${{ matrix.target }}/release/cora + + - name: Extract version + id: version shell: bash + run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT + + - name: Package (Unix) + if: runner.os != 'Windows' + run: | + mkdir -p release + cp target/${{ matrix.target }}/release/cora release/cora + cd release + tar czf ${{ matrix.artifact }}-${{ steps.version.outputs.VERSION }}.${{ matrix.ext }} cora + + - name: Package (Windows) + if: runner.os == 'Windows' + shell: pwsh run: | - cd target/${{ matrix.target }}/release - tar czf ../../../${{ matrix.asset_name }}.tar.gz ${{ matrix.artifact_name }} + mkdir release + cp target/${{ matrix.target }}/release/cora.exe release/${{ matrix.artifact }}.exe + cd release + 7z a ${{ matrix.artifact }}-${{ steps.version.outputs.VERSION }}.${{ matrix.ext }} ${{ matrix.artifact }}.exe - name: Upload artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: - name: ${{ matrix.asset_name }} - path: ${{ matrix.asset_name }}.tar.gz + name: ${{ matrix.artifact }}-${{ steps.version.outputs.VERSION }} + path: release/${{ matrix.artifact }}-${{ steps.version.outputs.VERSION }}.${{ matrix.ext }} - publish-github-release: - name: Publish GitHub Release - needs: build-release + publish-crates: + name: Publish to crates.io + needs: [] runs-on: ubuntu-latest + permissions: + id-token: write steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + + - name: Publish to crates.io + run: cargo publish --token ${{ secrets.CARGO_REGISTRY_TOKEN }} --allow-dirty + + release: + name: Create GitHub Release + needs: [build-release, publish-crates] + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - uses: actions/checkout@v4 + - name: Download all artifacts uses: actions/download-artifact@v4 with: - path: artifacts/ + path: release-artifacts + merge-multiple: true + + - name: List artifacts + run: ls -la release-artifacts/ + + - name: Generate SHA256 checksums + run: | + cd release-artifacts + sha256sum * > checksums-sha256.txt + cat checksums-sha256.txt + + - name: Extract version from tag + id: version + run: | + TAG_VERSION="${GITHUB_REF#refs/tags/}" + SEMVER="${TAG_VERSION#v}" + # Validate semver format (digits.digits.digits only โ€” prevents sed injection) + if ! echo "$SEMVER" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "ERROR: Invalid semver format: $SEMVER" + exit 1 + fi + echo "TAG=$TAG_VERSION" >> $GITHUB_OUTPUT + echo "VERSION=$SEMVER" >> $GITHUB_OUTPUT + + - name: Extract changelog for this version + id: changelog + run: | + CHANGELOG_EXCERPT=$(sed -n '/^## \['"${{ steps.version.outputs.VERSION }}"'\]/,/^## \[/p' CHANGELOG.md | sed '$d' | tail -n +2) + { + printf '## What'\''s New in %s\n\n' "${{ steps.version.outputs.TAG }}" + printf '%s\n\n' "$CHANGELOG_EXCERPT" + printf '### ๐Ÿ“ฆ Platforms\n\n' + printf '| Platform | File |\n' + printf '|----------|------|\n' + printf '| Linux (x86_64) | `cora-x86_64-unknown-linux-gnu-%s.tar.gz` |\n' "${{ steps.version.outputs.TAG }}" + printf '| Linux (ARM64) | `cora-aarch64-unknown-linux-gnu-%s.tar.gz` |\n' "${{ steps.version.outputs.TAG }}" + printf '| macOS (Apple Silicon) | `cora-aarch64-apple-darwin-%s.tar.gz` |\n' "${{ steps.version.outputs.TAG }}" + printf '| Windows (x86_64) | `cora-x86_64-pc-windows-msvc-%s.zip` |\n\n' "${{ steps.version.outputs.TAG }}" + printf '### ๐Ÿš€ Quick Start\n\n' + printf '```bash\n' + printf '# Quick install (Linux / macOS)\n' + printf 'curl -fsSL https://raw.githubusercontent.com/codecoradev/cora-cli/main/install.sh | sh\n\n' + printf '# Or via cargo\n' + printf 'cargo install cora\n\n' + printf '# Init project\n' + printf 'cora init\n\n' + printf '# Review staged changes\n' + printf 'CORA_API_KEY=your-key cora review --staged\n' + printf '```\n\n' + printf '**Full changelog:** https://github.com/codecoradev/cora-cli/blob/main/CHANGELOG.md\n' + } > release-notes.md - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: - generate_release_notes: true - files: artifacts/**/*.tar.gz + tag_name: ${{ steps.version.outputs.TAG }} + name: Release ${{ steps.version.outputs.TAG }} + body_path: release-notes.md + files: release-artifacts/* + draft: false + prerelease: ${{ contains(steps.version.outputs.TAG, '-') }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - publish-crates-io: - name: Publish to crates.io - needs: build-release - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - - - name: Publish to crates.io - run: cargo publish --token ${{ secrets.CRATES_TOKEN }} diff --git a/AGENT.md b/AGENT.md new file mode 100644 index 00000000..5c46d0e6 --- /dev/null +++ b/AGENT.md @@ -0,0 +1,147 @@ +# AGENT.md โ€” AI-Assisted Development Onboarding + +## Project Overview + +**cora-cli** is a Rust CLI tool for AI-powered code review. Bring Your Own Keys (BYOK) โ€” +no managed API, no cloud service. Runs locally against diffs, scans, or branches. + +- **License:** MIT +- **Edition:** Rust 2024 (MSRV 1.85) +- **Repo:** `codecoradev/cora-cli` +- **Default branch:** `develop` + +## Build & Development Commands + +```bash +cargo build # Build (debug) +cargo build --release # Build (release) +cargo test # Run all 224 tests +cargo clippy # Lint +cargo fmt # Format check (use -- --check for CI) +``` + +## Code Structure (`src/`) + +``` +src/ +โ”œโ”€โ”€ main.rs # Entry point, CLI argument parsing +โ”œโ”€โ”€ commands/ # Subcommand handlers +โ”‚ โ”œโ”€โ”€ mod.rs +โ”‚ โ”œโ”€โ”€ review.rs # cora review (diff-based review) +โ”‚ โ”œโ”€โ”€ scan.rs # cora scan (full-file scan) +โ”‚ โ”œโ”€โ”€ config_cmd.rs # cora config (show/set/validate) +โ”‚ โ”œโ”€โ”€ auth.rs # cora auth (API key management) +โ”‚ โ”œโ”€โ”€ hook_cmd.rs # cora hook (pre-commit hook install/uninstall) +โ”‚ โ”œโ”€โ”€ init.rs # cora init (project scaffolding) +โ”‚ โ”œโ”€โ”€ upload.rs # cora upload (review upload) +โ”‚ โ”œโ”€โ”€ completion.rs # Shell completion generation +โ”‚ โ””โ”€โ”€ providers.rs # cora providers (list providers) +โ”œโ”€โ”€ config/ +โ”‚ โ”œโ”€โ”€ mod.rs +โ”‚ โ”œโ”€โ”€ schema.rs # Config structs & defaults +โ”‚ โ”œโ”€โ”€ loader.rs # Config loading & priority chain +โ”‚ โ””โ”€โ”€ providers.rs # LLM provider definitions +โ”œโ”€โ”€ engine/ +โ”‚ โ”œโ”€โ”€ mod.rs +โ”‚ โ”œโ”€โ”€ review.rs # Review orchestration logic +โ”‚ โ”œโ”€โ”€ scanner.rs # File scanning engine +โ”‚ โ”œโ”€โ”€ llm.rs # LLM API interaction +โ”‚ โ””โ”€โ”€ types.rs # Severity, finding, and result types +โ”œโ”€โ”€ formatters/ # Output format implementations +โ”‚ โ”œโ”€โ”€ mod.rs +โ”‚ โ”œโ”€โ”€ pretty.rs # Human-readable terminal output +โ”‚ โ”œโ”€โ”€ compact.rs # Compact single-line output +โ”‚ โ”œโ”€โ”€ json_fmt.rs # JSON output +โ”‚ โ””โ”€โ”€ sarif.rs # SARIF format (CI integration) +โ”œโ”€โ”€ git/ +โ”‚ โ”œโ”€โ”€ mod.rs +โ”‚ โ”œโ”€โ”€ diff.rs # Git diff parsing +โ”‚ โ””โ”€โ”€ files.rs # File listing & filtering +โ””โ”€โ”€ hook/ + โ”œโ”€โ”€ mod.rs + โ”œโ”€โ”€ install.rs # Hook install/uninstall logic + โ””โ”€โ”€ template.rs # Hook script templates +``` + +## Key Files + +| File | Purpose | +|---|---| +| `commands/config_cmd.rs` | Config subcommand โ€” display, set, validate, path resolution | +| `config/loader.rs` | Config loading with full priority chain resolution | +| `config/schema.rs` | All config structs, defaults, serde annotations | +| `commands/init.rs` | Project scaffolding, `.cora.yaml` generation | + +## Testing + +```bash +cargo test # 205 tests total + # 183 unit tests + # 16 CLI integration tests + # 6 config tests +cargo test --no-verify # Skip pre-commit hooks (avoids timeout in hooks) +``` + +Use `--no-verify` when running tests through pre-commit hooks to prevent nested +hook execution and timeouts. + +## CI/CD + +Three GitHub Actions workflows in `.github/workflows/`: + +1. **ci.yml** โ€” PR checks: build, test, clippy, fmt on push to `develop` and PRs +2. **release.yml** โ€” Triggered by `v*` tags; builds for 4 platforms (Linux x86_64, + macOS x86_64, macOS ARM64, Windows x86_64), publishes to crates.io +3. **deploy-website.yml** โ€” Deploys project website/docs + +## Config System + +### Priority Chain (highest to lowest) + +1. **CLI flags** (`--provider`, `--model`, etc.) +2. **Environment variables** (`CORA_PROVIDER`, `CORA_MODEL`, etc.) +3. **Project config** (`.cora.yaml` in repo root) +4. **Global config** (`~/.cora/config.yaml`) +5. **Built-in defaults** (defined in `config/schema.rs`) + +### Auth Separation + +API keys live in a separate `auth.toml` file (`~/.cora/auth.toml`), not in +`.cora.yaml`. This prevents accidental key leakage via committed config files. + +## Design Decisions + +- **Severity comparison uses `<=` not `>=`**: Severity filtering uses `<=` because + the ordinal maps info=1, warning=2, error=3 โ€” so `--severity warning` means + "include warning and below (info)", not "warning and above (error)". This is + intentional; the mapping is defined in `engine/types.rs`. +- **TOML โ†’ YAML migration**: Config format migrated from TOML to YAML for better + readability and broader tooling support. The loader only reads YAML now. +- **Auth/config separation**: API keys are deliberately stored in `auth.toml` + rather than the main config to allow `.cora.yaml` to be safely committed to + repositories without exposing secrets. +- **LLM JSON repair**: `engine/llm.rs` includes `repair_invalid_escapes()` โ€” a state + machine that fixes invalid JSON escape sequences produced by some LLMs (e.g. + `\s`, `\d` โ†’ `\\s`, `\\d`). Applied before `serde_json::from_str` in all parse paths. + `review_diff()` also retries once on parse failure. +- **Temperature 0 (deterministic)**: Default temperature is 0 (v0.1.7+). Same diff + produces identical review output every run. Cache key includes model + temperature. +- **Per-request HTTP timeout**: `reqwest::Client` shared via `LazyLock` (connection + pooling). Timeout set per-request via `.timeout()` on RequestBuilder, not at + client construction (client-level is misleading). +- **Diff-hash caching**: Reviews cached in `~/.cache/cora/reviews/` by SHA-256 of + diff + model + temperature. TTL configurable via `llm.cache_ttl`. Bypass with + `--no-cache`. Cache stores fully-filtered response (after anti-hallucination). +- **Anti-hallucination**: File paths extracted from diff headers, injected into + prompt. Post-parse filtering removes issues referencing files not in diff. + `system_prompt_file` path traversal guard (canonicalized root check). +- **Composite action resilience**: Version resolution retries 3x with fallback. + `curl -sfL` (fail on HTTP errors). `d.get('tag_name', '')` (no KeyError). +- **Release workflow v-prefix**: `release.yml` strips `v` from git tag to match + CHANGELOG `[X.Y.Z]` format. `TAG` (with v) for display/URLs, `VERSION` (without) + for changelog sed matching and asset naming. +- **Infisical secrets**: All workflows use `secrets.INFISICAL_IDENTITY_ID` โ€” never + hardcode identity IDs. +- **Documentation update before release**: README config/features/flags, website + configuration docs page, and homepage feature bullets MUST be updated to reflect + new features BEFORE version bump and tagging. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..76af9f71 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,254 @@ +# Changelog + +All notable changes to cora-cli are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- **Deterministic rule engine** โ€” pre-LLM regex-based rules that always report findings (no LLM dismissal). Includes security (hardcoded URLs, secrets, TLS disabled, debug prints), SQL injection, and TODO markers (#116) +- **Custom rules via `.cora.yaml`** โ€” define project-specific regex rules with severity, category, exclude patterns, and glob file matching +- **Unified diff parser** โ€” parse git diff into structured `FileChunk`/`DiffHunk`/`DiffLine` with language detection for 70+ extensions +- **File bundling engine** โ€” smart grouping by directory and language family with configurable character/file limits. Defers full parallel review to v0.5 (#115) +- **Cross-file context chain** โ€” deterministic symbol extraction (imports, function calls, type references) for 5 languages (Rust, Python, JS, Go, Java) with token-budgeted context injection into LLM prompt (#114) +- **`BundlingConfig`** โ€” `strategy`, `max_chars_per_group`, `max_files_per_group`, `coalesce_by_directory`, `coalesce_by_language` in `.cora.yaml` +- **`ContextConfig`** โ€” `enabled`, `max_context_tokens`, `follow_depth`, `max_symbols` in `.cora.yaml` review section + +### Changed + +- **Review pipeline** โ€” rules engine runs before LLM call, context chain enriches LLM prompt with cross-file dependencies + +## [0.3.0] - 2026-06-03 + +### Added + +- **Static analysis context injection** โ€” optional clippy output injected into review prompt to reduce false positives on verified-intentional changes (#140) +- **`review.static_analysis.auto_clippy`** config โ€” automatically run `cargo clippy` and filter output to changed files +- **`review.static_analysis.clippy_output_file`** config โ€” read pre-computed clippy output from file +- **`cora config validate`** subcommand โ€” validate `.cora.yaml` configuration file and report issues (#88) +- **`CoraError` enum via thiserror** โ€” structured error types for engine layer with 17 variants (#86) + +### Changed + +- **Engine layer migrated from `anyhow` to `thiserror`** โ€” structured error handling in engine, `anyhow` retained in CLI layer (#86) +- **All clippy pedantic warnings resolved** โ€” 175 โ†’ 0 warnings across entire codebase (#84) +- **Repo URLs updated** to `codecoradev/cora-cli` org (#137) +- **CI actions bumped** โ€” `upload-artifact@v7`, Node 24 strict mode (`FORCE_JAVASCRIPT_ACTIONS_TO_NODE24`) (#142) + +### Fixed + +- **CI Cora Review fails on LLM API errors** โ€” removed `|| true` suppression, added exit code + empty SARIF check (#142) +- **Match arm merge in `IssueType::from_str`** โ€” clarified documentation (#141) + +## [0.2.0] - 2026-06-02 + +### Added + +- **`--progress` flag** โ€” NDJSON progress events to stderr for structured CI/GUI consumers (Termul prerequisite) (#108) +- **`--max-diff-size` flag** โ€” override `hook.max_diff_size` for large diffs from CLI (#112) +- **Output footer watermark** โ€” Cora version stamp in terminal, SARIF, and JSON output when issues found (#106) +- **Security audit CI** โ€” `cargo audit` via `rustsec/audit-check` for dependency CVE scanning (#85) + +### Changed + +- **Naive .gitignore parser โ†’ `ignore` crate** โ€” ripgrep-grade correctness with nested .gitignore, global gitignore, and `.git/info/exclude` support (#80) +- **Blanket `#![allow(dead_code)]` removed** โ€” targeted cleanup, 27 warnings โ†’ 0 (#79) + +### Fixed + +- **`REQUESTS_CA_BUNDLE` env var support** โ€” custom CA certificates for corporate proxies, additive to built-in root certs (#74) +- **`tls_built_in_root_certs(false)` security fix** โ€” custom CA bundle now added alongside system roots instead of replacing them (caught by Cora self-review) +- **`require_git(false)` on WalkBuilder** โ€” gitignore rules applied even outside git repositories (#112) +- **CI `actions-rs/audit-check` โ†’ `rustsec/audit-check`** โ€” replaced archived GitHub Action (#112) +- **Cora CI diff limit** โ€” `CORA_CONFIG` env var with temp config for 200K char limit in CI action (#112) + +## [0.1.8] - 2026-06-02 + +### Fixed + +- **`unwrap()` โ†’ `expect()`** in ProgressStyle templates (llm.rs, scanner.rs) โ€” clearer panic messages on template parse failure (#87) +- **Consolidated duplicate `impl Severity` blocks** into single implementation (#83) +- **`file_content_hash` returns `Option`** instead of empty string on read failure โ€” prevents infinite rescan loop on unreadable files (#77) +- **Permission errors logged in scanner** โ€” file walk now logs permission errors at debug level instead of silently skipping (#76) +- **Auth file permission warning** โ€” warns if `~/.cora/auth.toml` has overly permissive file permissions (Unix only) (#72) +- **SARIF upload size validation** โ€” validates SARIF file size against GitHub's 10MB limit before upload (#82) +- **Float division for MB display** โ€” SARIF size error now shows accurate fractional MB (was integer division truncating to 0) (#82) +- **Non-deterministic `DefaultHasher` โ†’ `sha2`** โ€” scan cache now uses SHA-256 for deterministic hashing across Rust versions (#81) + +### Added + +- **`checksums-sha256.txt` in release artifacts** โ€” release workflow generates SHA-256 checksums for all platform binaries (#109) + +### Changed + +- **Official CodeCora branding assets** โ€” logo, favicon, and OG image updated from ajianaz/cora SaaS repo (#110) +- **Standalone `cora-review.yml` workflow** โ€” CI action extracted from inline `ci.yml` job to dedicated workflow with concurrency control (#107) +- **Action v2 hardened** โ€” all third-party actions pinned to commit SHA, checksum verification for binary downloads, env var indirection for inputs, `grep` pipefail fix, empty file guard, Node 24 strict mode compatibility (#107) + +## [0.1.7] - 2026-06-01 + +### Added + +- **Diff-hash caching** โ€” review results cached by SHA-256 of diff + model + temperature in `~/.cache/cora/reviews/`. Cache TTL configurable via `llm.cache_ttl` (#100) +- **`--no-cache` flag** โ€” bypass cache for fresh reviews (#100) +- **Configurable LLM parameters** โ€” `llm.temperature` (default: 0), `llm.max_tokens` (default: 4096), `llm.timeout` (default: 120s), `llm.cache_ttl` (default: 1440 min) in `.cora.yaml` (#98 #101) +- **Git ref validation** โ€” rejects refs containing shell metacharacters or path traversal sequences (#73) + +### Fixed + +- **Temperature default now 0** โ€” eliminates non-deterministic LLM output. Same diff produces identical issues on every run (#98, #97) +- **HTTP timeout actually works** โ€” per-request timeout via reqwest RequestBuilder (not client-level). Configurable timeout respected (#99) +- **Connection pooling** โ€” shared reqwest::Client via LazyLock, reused across all requests (#99) +- **Cache key includes model + temperature** โ€” config changes invalidate cache automatically (#100) +- **Silent config corruption** โ€” malformed `.cora.yaml` now shows clear error with file path and hint (#78) +- **Composite action KeyError on API failure** โ€” version resolution retries 3x with 5s delay, falls back to v0.1.6 with warning. Fixed in both `cora-review` and `cora-review-simple` actions (#102) + +## [0.1.6] - 2026-06-01 + +### Added + +- **Custom system prompts via config** โ€” `review.system_prompt`, `review.system_prompt_file`, `scan.system_prompt`, `scan.system_prompt_file` fields in `.cora.yaml` (#94) +- **`response_format` config** โ€” opt-in `json_object` response format for providers that support it, via `review.response_format: json_object` (#92) +- **File path injection into prompts** โ€” valid diff file paths are injected into the review user prompt to reduce LLM hallucination (#93) +- **Post-parse file path filtering** โ€” issues referencing non-existent files are filtered out after LLM response parsing (#93) +- **Enhanced default system prompts** โ€” both review and scan prompts now include explicit anti-hallucination constraints, severity definitions, and format instructions (#95) + +### Fixed + +- **Path traversal in `system_prompt_file`** โ€” arbitrary file read vulnerability. Now validates file path is within canonicalized project root (#92) +- **Symlink bypass in path traversal guard** โ€” project root is now canonicalized to match resolved file paths + +## [0.1.5] - 2026-06-01 + +### Fixed + +- **Critical: JSON repair corrupts valid unicode escapes** โ€” `is_valid_json_escape()` missing `'u'`, causing `\uXXXX` to be double-escaped. Now properly validates and handles incomplete `\u` sequences (#89) +- **Critical: TOML injection in `save_api_key()`** โ€” API key written via `format!` string interpolation. Now uses `toml::Table` serialization (#69) +- **Retry prompt improvement** โ€” retry on parse failure now includes stricter JSON format instructions (#90) +- **Temp file race condition** โ€” SARIF upload now uses PID-suffixed temp path instead of fixed filename (#70) +- **Confusing unused `_cli_api_key` parameter** โ€” removed from `load_config()` signature (#75) + +### Security + +- `save_api_key()` now uses `toml::Table::insert()` instead of string interpolation (prevents TOML injection) +- Temp SARIF file path includes process ID (prevents TOCTOU race) + +## [0.1.4] - 2026-06-01 + +### Added + +- LLM JSON repair engine (`repair_invalid_escapes`) โ€” auto-fixes invalid escape sequences in LLM output (e.g. `\s`, `\d`) before JSON parse +- Retry mechanism in `review_diff` โ€” if first LLM parse fails, automatically retries once +- Branding footer on "No issues found" PR comment โ€” consistent with issues-found variant + +### Fixed + +- **Silent false-negative** โ€” cora JSON parse failure previously posted "No issues found" without actual review (LLM invalid escapes) +- Hardcoded Infisical `identity-id` in `release.yml` and `deploy-website.yml` โ€” migrated to `secrets.INFISICAL_IDENTITY_ID` +- Release workflow changelog extraction โ€” `v` prefix mismatch (tag `v0.1.3` vs CHANGELOG `[0.1.3]`) now properly stripped +- `printf` double-escape in release workflow โ€” `\\n` corrected to `\n` +- Stale `v0.1.2` binary download filenames in README +- Clippy `unnecessary_map_or` lint โ€” `.map_or(false, |s| s.success())` replaced with `.is_ok_and(|s| s.success())` + +### Changed + +- All 3 workflows use `secrets.INFISICAL_IDENTITY_ID` (consistent with `ci.yml` pattern) +- Release workflow validates semver format before sed injection +- Branch cleanup โ€” removed 14 stale branches + +## [0.1.3] - 2026-06-01 + +### Added + +- `cora config set --global` โ€” write config to `~/.cora/config.yaml` instead of project `.cora.yaml` +- `cora config set base_url` โ€” set base URL via CLI (previously only in YAML) +- Global config support (`~/.cora/config.yaml`) with priority chain: CLI flags โ†’ env vars โ†’ project โ†’ global โ†’ defaults +- Auto-migration from old `~/.cora/config.toml` to new YAML + `auth.toml` split + +### Changed + +- `cora config set` now writes YAML instead of TOML (compatible with config loader) +- API key storage moved from `~/.cora/config.toml` to `~/.cora/auth.toml` (0600 permissions) +- YAML serialization uses `skip_serializing_if` โ€” no more `null` values in output + +### Fixed + +- **Severity comparison inverted** โ€” `Critical` issues no longer silently pass `should_block` check (Ord ordering bug) +- Hook `mode: block` no longer exits with code 2 when "No issues found" (severity filter mismatch) +- Consistent severity logic across review, scan, and block mode paths + +## [0.1.2] - 2025-05-29 + +### Added + +- `cora init` โ€” create `.cora.yaml` config file with provider/model selection +- `cora hook install|uninstall` โ€” pre-commit hook management +- `cora config show|set` โ€” configuration management +- CI composite action (`cora-review-simple`) for easy GitHub Actions integration +- Shell completions for bash, zsh, fish, and powershell +- `cora scan --incremental` with SHA256 content hash cache for fast incremental scanning +- `cora review --upload` for direct SARIF upload to GitHub Code Scanning +- `cora review --stream` for real-time review output +- `cora review --unpushed` for reviewing unpushed commits +- `cora review --base ` for branch comparison +- `cora review --diff-file ` for reviewing external diff files +- `cora providers` command to list available LLM providers +- `cora auth login` for interactive API key storage + +### Fixed + +- SARIF schema compliance for GitHub Code Scanning upload +- Clippy `format_in_format_args` warnings +- Replaced deprecated `serde_yaml` with `serde_yaml_ng` +- Normalized release binary naming (`cora-{arch}-{target}-v{version}.tar.gz`) + +### Changed + +- Replaced deprecated dependencies +- Removed unused dependencies +- Bumped minimum Rust version to 1.85 + +## [0.1.1] - 2025-05-27 + +### Changed + +- Replaced ASCII art banner with eye icon in README +- Updated README branding to cora-cli + +### Fixed + +- CI `cargo publish` with `--allow-dirty` for Cargo.lock mismatch on tag checkout + +## [0.1.0] - 2025-05-25 + +### Added + +- **AI Code Review** โ€” review staged changes, commit ranges, branch diffs, and full project scans +- **BYOK** โ€” bring your own API key (OpenAI, Anthropic, Groq, Ollama, Google) +- **5 LLM Providers** โ€” with auto-detection from installed API keys +- **Pre-commit Hooks** โ€” `cora hook install` for automatic review on every commit +- **SARIF Output** โ€” `--format sarif` for GitHub Code Scanning integration +- **4 Output Formats** โ€” pretty (colored), compact, JSON, SARIF +- **Project Config** โ€” `.cora.yaml` per-project configuration with provider, focus, rules, ignore, and hook settings +- **Environment Variables** โ€” `CORA_API_KEY`, `CORA_MODEL`, `CORA_PROVIDER`, `CORA_BASE_URL`, `CORA_CONFIG`, `CORA_FORMAT` +- **Severity Levels** โ€” `info`, `minor`, `major`, `critical` with configurable thresholds +- **Focus Areas** โ€” `security`, `performance`, `bugs`, `best_practice`, `maintainability` +- **Ignore Rules** โ€” file patterns and rule-level exclusions +- **Cross-platform** โ€” Linux (x86_64, ARM64), macOS (Apple Silicon), Windows (x86_64) +- **MIT License** โ€” fully open source + +[Unreleased]: https://github.com/codecoradev/cora-cli/compare/v0.3.0...develop +[0.3.0]: https://github.com/codecoradev/cora-cli/compare/v0.2.0...v0.3.0 +[0.2.0]: https://github.com/codecoradev/cora-cli/compare/v0.1.8...v0.2.0 +[0.1.8]: https://github.com/codecoradev/cora-cli/compare/v0.1.7...v0.1.8 +[0.1.7]: https://github.com/codecoradev/cora-cli/compare/v0.1.6...v0.1.7 +[0.1.6]: https://github.com/codecoradev/cora-cli/compare/v0.1.5...v0.1.6 +[0.1.5]: https://github.com/codecoradev/cora-cli/compare/v0.1.4...v0.1.5 +[0.1.4]: https://github.com/codecoradev/cora-cli/compare/v0.1.3...v0.1.4 +[0.1.3]: https://github.com/codecoradev/cora-cli/compare/v0.1.2...v0.1.3 +[0.1.2]: https://github.com/codecoradev/cora-cli/compare/v0.1.1...v0.1.2 +[0.1.1]: https://github.com/codecoradev/cora-cli/compare/v0.1.0...v0.1.1 +[0.1.0]: https://github.com/codecoradev/cora-cli/releases/tag/v0.1.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bd6274cb..9ec39324 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,7 +21,7 @@ Be respectful, constructive, and inclusive. We follow the [Rust Code of Conduct] ### Prerequisites -- **Rust** 1.75+ (stable toolchain recommended) +- **Rust** 1.85+ (stable toolchain recommended) - **Git** for version control - An **LLM API key** (OpenAI, Anthropic, etc.) for testing review features @@ -29,7 +29,7 @@ Be respectful, constructive, and inclusive. We follow the [Rust Code of Conduct] ```bash # Clone the repository -git clone https://github.com/ajianaz/cora-cli.git +git clone https://github.com/codecoradev/cora-cli.git cd cora-cli # Build in debug mode @@ -47,14 +47,30 @@ cargo clippy -- -D warnings ``` cora-cli/ โ”œโ”€โ”€ src/ -โ”‚ โ”œโ”€โ”€ main.rs # CLI entry point -โ”‚ โ”œโ”€โ”€ cli.rs # Argument parsing (clap) -โ”‚ โ”œโ”€โ”€ config.rs # Configuration management -โ”‚ โ”œโ”€โ”€ scanner.rs # File scanning and diff generation -โ”‚ โ”œโ”€โ”€ reviewer.rs # LLM integration for code review -โ”‚ โ”œโ”€โ”€ formatter.rs # Output formatting -โ”‚ โ””โ”€โ”€ ci.rs # CI/CD integration helpers -โ”œโ”€โ”€ tests/ # Integration tests +โ”‚ โ”œโ”€โ”€ main.rs # CLI entry point + clap args +โ”‚ โ”œโ”€โ”€ cli.rs # Argument parsing (clap) +โ”‚ โ”œโ”€โ”€ commands/ # CLI subcommands +โ”‚ โ”‚ โ”œโ”€โ”€ review.rs # cora review +โ”‚ โ”‚ โ”œโ”€โ”€ scan.rs # cora scan +โ”‚ โ”‚ โ”œโ”€โ”€ config_cmd.rs # cora config show/set +โ”‚ โ”‚ โ”œโ”€โ”€ auth.rs # cora auth login/logout +โ”‚ โ”‚ โ””โ”€โ”€ init.rs # cora init +โ”‚ โ”œโ”€โ”€ config/ +โ”‚ โ”‚ โ”œโ”€โ”€ loader.rs # Config resolution chain (global โ†’ project โ†’ env โ†’ CLI) +โ”‚ โ”‚ โ”œโ”€โ”€ schema.rs # YAML config structs + merge logic +โ”‚ โ”‚ โ””โ”€โ”€ providers.rs # Provider presets (OpenAI, Anthropic, etc.) +โ”‚ โ”œโ”€โ”€ engine/ +โ”‚ โ”‚ โ”œโ”€โ”€ review.rs # LLM review + SARIF generation +โ”‚ โ”‚ โ”œโ”€โ”€ scanner.rs # File scanning and diff generation +โ”‚ โ”‚ โ”œโ”€โ”€ types.rs # Issue, Severity, ScanResponse types +โ”‚ โ”‚ โ””โ”€โ”€ llm.rs # LLM API abstraction +โ”‚ โ””โ”€โ”€ formatter/ +โ”‚ โ””โ”€โ”€ mod.rs # Output formatting (pretty, compact, json, sarif) +โ”œโ”€โ”€ .github/ +โ”‚ โ”œโ”€โ”€ workflows/ # CI, release, deploy workflows +โ”‚ โ””โ”€โ”€ actions/ +โ”‚ โ””โ”€โ”€ cora-review/ # Composite action for GitHub Actions +โ”œโ”€โ”€ tests/ # Integration tests โ”œโ”€โ”€ Cargo.toml โ””โ”€โ”€ README.md ``` @@ -68,7 +84,7 @@ cora-cli/ ``` 3. **Make your changes** and commit with meaningful messages: ```bash - git commit -s -m "feat: add support for SARIF output format" + git commit -m "feat: add support for SARIF output format" ``` 4. **Push** to your fork and open a **Pull Request** @@ -111,16 +127,17 @@ mod tests { use super::*; #[test] - fn test_scan_single_file() { - let result = scan_files(vec!["src/main.rs".into()]); - assert!(!result.is_empty()); + fn test_severity_from_str() { + use crate::engine::types::Severity; + assert_eq!(Severity::from_str_lossy("critical"), Severity::Critical); + assert_eq!(Severity::from_str_lossy("info"), Severity::Info); } } ``` ## Reporting Bugs -Please open a [GitHub Issue](https://github.com/ajianaz/cora-cli/issues/new) with: +Please open a [GitHub Issue](https://github.com/codecoradev/cora-cli/issues/new) with: - **Description** โ€” What happened vs. what you expected - **Steps to reproduce** โ€” Minimal reproduction steps diff --git a/Cargo.lock b/Cargo.lock index dc04cdc5..9bfaaa16 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -115,6 +115,15 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bstr" version = "1.12.1" @@ -171,6 +180,7 @@ dependencies = [ "iana-time-zone", "js-sys", "num-traits", + "serde", "wasm-bindgen", "windows-link", ] @@ -197,6 +207,15 @@ dependencies = [ "strsim", ] +[[package]] +name = "clap_complete" +version = "4.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0a7a9bfdb35811f9e59832f0f05975114d2251b415fb534108e6f34060fd772" +dependencies = [ + "clap", +] + [[package]] name = "clap_derive" version = "4.6.1" @@ -243,25 +262,28 @@ dependencies = [ ] [[package]] -name = "cora" -version = "0.1.0" +name = "cora-cli" +version = "0.4.0" dependencies = [ "anyhow", "assert_cmd", "chrono", "clap", + "clap_complete", "colored", "dirs", + "futures-util", "git2", "glob", + "ignore", "indicatif", - "owo-colors", "predicates", "regex", "reqwest", "serde", "serde_json", - "serde_yaml", + "serde_yaml_ng", + "sha2", "tempfile", "thiserror", "tokio", @@ -269,7 +291,6 @@ dependencies = [ "toml", "tracing", "tracing-subscriber", - "walkdir", ] [[package]] @@ -278,12 +299,66 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "difflib" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "dirs" version = "6.0.0" @@ -434,6 +509,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -493,6 +578,19 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -744,6 +842,22 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "ignore" +version = "0.4.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -872,15 +986,6 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - [[package]] name = "log" version = "0.4.30" @@ -961,35 +1066,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" -[[package]] -name = "owo-colors" -version = "4.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" - -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", -] - [[package]] name = "percent-encoding" version = "2.3.2" @@ -1186,15 +1262,6 @@ dependencies = [ "getrandom 0.3.4", ] -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags", -] - [[package]] name = "redox_users" version = "0.5.2" @@ -1365,12 +1432,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - [[package]] name = "semver" version = "1.0.28" @@ -1442,10 +1503,10 @@ dependencies = [ ] [[package]] -name = "serde_yaml" -version = "0.9.34+deprecated" +name = "serde_yaml_ng" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" dependencies = [ "indexmap", "itoa", @@ -1454,6 +1515,17 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -1469,16 +1541,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" -[[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - [[package]] name = "slab" version = "0.4.12" @@ -1632,9 +1694,7 @@ dependencies = [ "bytes", "libc", "mio", - "parking_lot", "pin-project-lite", - "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", @@ -1849,6 +1909,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -1921,6 +1987,12 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wait-timeout" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 9eda2d20..418d37bc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,10 +1,10 @@ [package] -name = "cora" -version = "0.1.0" +name = "cora-cli" +version = "0.4.0" edition = "2024" description = "CLI-first AI code review โ€” BYOK, diff/scan/branch, pre-commit hooks" license = "MIT" -repository = "https://github.com/ajianaz/cora-cli" +repository = "https://github.com/codecoradev/cora-cli" readme = "README.md" keywords = ["cli", "code-review", "ai", "git", "pre-commit"] categories = ["command-line-utilities", "development-tools"] @@ -13,9 +13,10 @@ rust-version = "1.85" [dependencies] # CLI clap = { version = "4", features = ["derive", "env"] } +clap_complete = "4" -# Async runtime -tokio = { version = "1", features = ["full"] } +# Async runtime (only what we need) +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } # HTTP client (OpenAI-compatible API calls) reqwest = { version = "0.12", features = ["json", "stream", "rustls-tls"], default-features = false } @@ -23,20 +24,19 @@ reqwest = { version = "0.12", features = ["json", "stream", "rustls-tls"], defau # Serialization serde = { version = "1", features = ["derive"] } serde_json = "1" -serde_yaml = "0.9" +serde_yaml_ng = "0.10" # Git operations git2 = { version = "0.20", default-features = false, features = ["vendored-libgit2"] } # Terminal output colored = "3" -owo-colors = "4" indicatif = "0.18" # File system dirs = "6" glob = "0.3" -walkdir = "2" +ignore = "0.4" # Error handling anyhow = "1" @@ -50,8 +50,10 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } # Misc -chrono = "0.4" regex = "1" +futures-util = "0.3" +sha2 = "0.10" +chrono = { version = "0.4.44", features = ["serde"] } [dev-dependencies] assert_cmd = "2" diff --git a/README.md b/README.md index 19353016..da1b49a5 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,13 @@
-``` - __ ___ _ ___ ____ - / |/ /___ ____| | / _ \/ __/ - / /|_/ / __ \/ __/| | /| / , _/ /__ - / / / / /_/ / /__ | |/ |/ /| |/ /__/ -/_/ /_/\____/\___/ |__/|__/_/|_|\___/ +CodeCora - AI-Powered Code Review CLI -``` +**AI-Powered Code Review CLI** -[![CI](https://github.com/ajianaz/cora-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/ajianaz/cora-cli/actions/workflows/ci.yml) +[![CI](https://github.com/codecoradev/cora-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/codecoradev/cora-cli/actions/workflows/ci.yml) [![Crates.io](https://img.shields.io/crates/v/cora-cli.svg)](https://crates.io/crates/cora-cli) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -[![Rust](https://img.shields.io/badge/Rust-1.75+-orange.svg)](https://www.rust-lang.org/) +[![Rust](https://img.shields.io/badge/Rust-1.85+-orange.svg)](https://www.rust-lang.org/) **Cora** is a fast, opinionated CLI tool that uses LLMs to review your code changes โ€” directly in your terminal, CI/CD pipeline, or git hooks. @@ -24,7 +18,7 @@ ## โœจ Features - ๐Ÿ” **Git-Aware Scanning** โ€” Automatically detects staged, committed, or changed files -- ๐Ÿค– **Multi-LLM Support** โ€” Works with OpenAI, Anthropic, Google, and any OpenAI-compatible API +- ๐Ÿค– **Multi-LLM Support** โ€” Works with OpenAI, Anthropic, Google, Ollama, and any OpenAI-compatible API - ๐ŸŽจ **Beautiful Output** โ€” Colorized, structured review output with severity levels - ๐Ÿ—๏ธ **CI/CD Ready** โ€” Designed for GitHub Actions, GitLab CI, and any pipeline - โšก **Fast & Lightweight** โ€” Native Rust binary, no runtime dependencies @@ -32,44 +26,61 @@ - ๐Ÿ”ง **Configurable** โ€” YAML config file with project-level defaults - ๐Ÿช **Git Hooks** โ€” Pre-commit integration for instant feedback - ๐Ÿ“Š **Exit Codes** โ€” Non-zero exit on critical findings for pipeline gating +- ๐Ÿง  **Deterministic Reviews** โ€” Temperature 0 by default: same diff always produces the same issues +- ๐Ÿ’พ **Diff-Hash Caching** โ€” Reviews cached by diff hash in `~/.cache/cora/reviews/` โ€” skip repeat reviews with `--no-cache` +- ๐ŸŽฏ **Custom System Prompts** โ€” Override review/scan prompts via config or file path +- ๐Ÿ›ก๏ธ **Anti-Hallucination** โ€” File path injection and post-parse filtering keep LLM output grounded +- ๐ŸŒก๏ธ **Configurable LLM Params** โ€” Tune temperature, max tokens, timeout, and cache TTL per project ## ๐Ÿ“ฆ Installation -### Cargo (Recommended) +### Quick Install (Linux/macOS) ```bash -cargo install cora-cli +curl -fsSL https://raw.githubusercontent.com/codecoradev/cora-cli/main/install.sh | sh ``` -### Homebrew +Installs to `~/.local/bin`. Add to PATH if needed: ```bash -brew tap ajianaz/tap -brew install cora-cli +echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc # or ~/.zshrc ``` -### Binary Download - -Download the latest release from [GitHub Releases](https://github.com/ajianaz/cora-cli/releases): +Pin a specific version with `CORA_VERSION`: ```bash -# Linux x86_64 -curl -L https://github.com/ajianaz/cora-cli/releases/latest/download/cora-linux-x86_64.tar.gz | tar xz +CORA_VERSION=v0.4.0 curl -fsSL https://raw.githubusercontent.com/codecoradev/cora-cli/main/install.sh | sh +``` + +### Pre-built Binaries + +Download from [GitHub Releases](https://github.com/codecoradev/cora-cli/releases): + +| Platform | Asset | +|----------|-------| +| Linux x86_64 | `cora-x86_64-unknown-linux-gnu-v*.tar.gz` | +| Linux ARM64 | `cora-aarch64-unknown-linux-gnu-v*.tar.gz` | +| macOS Apple Silicon | `cora-aarch64-apple-darwin-v*.tar.gz` | +| Windows x86_64 | `cora-x86_64-pc-windows-msvc-v*.zip` | -# macOS ARM (Apple Silicon) -curl -L https://github.com/ajianaz/cora-cli/releases/latest/download/cora-macos-aarch64.tar.gz | tar xz +> **Windows users:** Extract the zip and place `cora.exe` somewhere in your PATH (e.g. `C:\Users\\.local\bin`). Run from Command Prompt, PowerShell, or Windows Terminal โ€” do not double-click the `.exe` (it will flash and close). -# macOS x86_64 (Intel) -curl -L https://github.com/ajianaz/cora-cli/releases/latest/download/cora-macos-x86_64.tar.gz | tar xz +### Cargo -# Windows x86_64 -curl -L https://github.com/ajianaz/cora-cli/releases/latest/download/cora-windows-x86_64.tar.gz | tar xz +```bash +cargo install --git https://github.com/codecoradev/cora-cli ``` +### Homebrew + +> ๐Ÿšง Homebrew tap is planned โ€” see [#151](https://github.com/codecoradev/cora-cli/issues/151). + ### Build from Source +Requires **Rust 1.85+**. + ```bash -git clone https://github.com/ajianaz/cora-cli.git +git clone https://github.com/codecoradev/cora-cli.git cd cora-cli cargo install --path . ``` @@ -84,16 +95,16 @@ export OPENAI_API_KEY="sk-..." export ANTHROPIC_API_KEY="sk-ant-..." ``` -### 2. Review Staged Changes +### 2. Initialize Config (Optional) ```bash -cora review +cora init ``` -### 3. Review a Specific File +### 3. Review Staged Changes ```bash -cora review src/main.rs +cora review --staged ``` ### 4. Review the Last Commit @@ -105,7 +116,7 @@ cora review --commit HEAD ### 5. Scan the Entire Project ```bash -cora scan . +cora scan ``` ## ๐Ÿ“– Commands @@ -115,32 +126,44 @@ cora scan . Review code changes using an LLM. ```bash -# Review staged files +# Review staged files (default) cora review -# Review specific files -cora review src/main.rs src/lib.rs +# Review unpushed changes +cora review --unpushed # Review a range of commits cora review --commit HEAD~3..HEAD -# Review a pull request diff +# Review changes vs a base branch +cora review --base origin/main + +# Review a pull request diff from a file cora review --diff-file pr.diff +# CI mode: skip diff size limit, hard gate on any findings +cora review --ci --base ${{ github.base_ref }} + # Use a specific model cora review --model gpt-4o -# Output as SARIF (for CI) -cora review --output sarif --output-file results.sarif +# Output as SARIF +cora review --format sarif # Output as JSON -cora review --output json +cora review --format json + +# Upload SARIF to GitHub Code Scanning (implies --format sarif) +cora review --upload # Set severity threshold -cora review --severity warning +cora review --severity major # Quiet mode (machine-readable) cora review --quiet + +# Skip cached reviews +cora review --no-cache ``` ### `cora scan` @@ -149,32 +172,49 @@ Scan files for code quality issues without requiring git context. ```bash # Scan current directory -cora scan . +cora scan -# Scan specific files -cora scan src/**/*.rs +# Scan a specific directory +cora scan --path src/ -# Scan with custom rules focus -cora scan . --focus security,performance +# Scan with focus areas +cora scan --focus security,performance # Exclude patterns -cora scan . --exclude "tests/**" --exclude "examples/**" +cora scan --exclude "tests/**" --exclude "examples/**" + +# Only scan changed files (incremental) +cora scan --incremental ``` ### `cora config` -Manage configuration. +Manage configuration. Supports both project-level (`.cora.yaml`) and global (`~/.cora/config.yaml`) config. ```bash -# Show current configuration +# Show current resolved configuration cora config show -# Initialize config file -cora config init - -# Set a configuration value +# Set a project-level value (writes to .cora.yaml) cora config set model claude-sonnet-4-20250514 -cora config set severity error +cora config set base_url https://api.openai.com/v1 +cora config set severity major + +# Set a global value (writes to ~/.cora/config.yaml) +cora config set --global model gpt-4o-mini +cora config set --global provider anthropic + +# Supported keys: model, provider, base_url, format, severity +``` + +**Priority**: CLI flags โ†’ env vars โ†’ `.cora.yaml` (project) โ†’ `~/.cora/config.yaml` (global) โ†’ defaults + +### `cora init` + +Create a `.cora.yaml` config file in the current directory. + +```bash +cora init ``` ### `cora completion` @@ -187,47 +227,83 @@ cora completion zsh > ~/.cora-completion.zsh cora completion fish > ~/.cora-completion.fish ``` +### `cora hook` + +Manage pre-commit git hooks. + +```bash +cora hook install +cora hook uninstall +``` + ## โš™๏ธ Configuration -Create a `.cora.yaml` in your project root or `~/.config/cora/config.yaml` globally: +Cora reads configuration from multiple sources in priority order: + +``` +CLI flags โ†’ CORA_* env vars โ†’ .cora.yaml (project) โ†’ ~/.cora/config.yaml (global) โ†’ defaults +``` + +Create a `.cora.yaml` in your project root, or use `~/.cora/config.yaml` for global settings. Project config always overrides global. ```yaml # .cora.yaml -model: gpt-4o -temperature: 0.1 # Provider configuration provider: - name: openai # openai | anthropic | google | custom - base_url: null # Override API endpoint (for custom/self-hosted) - api_key_env: OPENAI_API_KEY # Environment variable for API key - -# Review settings + provider: openai # openai | anthropic | google | ollama | custom + model: gpt-4o-mini + base_url: https://api.openai.com/v1 # Override for custom/self-hosted endpoints + +# LLM parameters +llm: + temperature: 0 # Default: 0 (deterministic โ€” same diff = same issues) + max_tokens: 4096 # Default: 4096 + timeout: 120 # Default: 120 (seconds) + cache_ttl: 1440 # Default: 1440 (minutes) โ€” diff-hash cache TTL + +# Focus areas for review (empty = all) +focus: + - security + - performance + - bugs + - best_practice + +# Review options review: - severity: warning # error | warning | info | note - focus: # Focus areas (empty = all) - - security - - performance - - maintainability - ignore: - patterns: - - "tests/**" - - "vendor/**" - - "*.generated.*" - rules: - - "line-too-long" - max_tokens: 4096 - language: en # Response language + system_prompt: "You are a senior Rust code reviewer." + # system_prompt_file: ./review-prompt.md # Load prompt from file + response_format: json_object # Opt-in structured JSON output + +# Scan options +# scan: +# system_prompt: "Focus on security vulnerabilities." +# system_prompt_file: ./scan-prompt.md + +# Custom rules +rules: + - "no unwrap" + +# Ignore configuration +ignore: + files: + - "tests/**" + - "vendor/**" + - "*.generated.*" + rules: + - "skip-rule-1" + +# Hook configuration +hook: + mode: warn # warn | block + min_severity: major # info | minor | major | critical + max_diff_size: 51200 # Max diff size in bytes (50 KB) + on_violation: warn # warn | disallow โ€” block commit on oversized diff # Output settings output: - format: colored # colored | plain | json | sarif - file: null # Output to file instead of stdout - -# Git settings -git: - auto_stage: false # Auto-stage files after review - base_branch: main # Base branch for PR reviews + format: pretty # pretty | json | compact | sarif + color: true ``` ### Environment Variables @@ -237,47 +313,60 @@ git: | `OPENAI_API_KEY` | OpenAI API key | โ€” | | `ANTHROPIC_API_KEY` | Anthropic API key | โ€” | | `GOOGLE_API_KEY` | Google AI API key | โ€” | -| `CORa_MODEL` | Override model | โ€” | -| `CORa_PROVIDER` | Override provider | โ€” | -| `CORa_CONFIG` | Path to config file | `.cora.yaml` | -| `CORa_LOG_LEVEL` | Log verbosity | `info` | +| `CORA_API_KEY` | API key (overrides provider-specific keys) | โ€” | +| `CORA_MODEL` | Override model | โ€” | +| `CORA_PROVIDER` | Override provider | โ€” | +| `CORA_BASE_URL` | Override API base URL | โ€” | +| `CORA_CONFIG` | Path to config file | `.cora.yaml` | +| `CORA_FORMAT` | Output format (`pretty`, `json`, `compact`, `sarif`) | `pretty` | +| `CORA_NO_COLOR` | Disable colored output | โ€” | +| `CORA_NO_CACHE` | Skip diff-hash cache (same as `--no-cache`) | โ€” | + +### Authentication + +API keys can be provided via environment variable (`CORA_API_KEY`), provider-specific env vars (`OPENAI_API_KEY`, etc.), or stored in `~/.cora/auth.toml` (auto-created by `cora auth login`, permission `0600`). + +```bash +# Interactive login (stores key in ~/.cora/auth.toml) +cora auth login + +# Or set via environment variable +export CORA_API_KEY=sk-... +``` ## ๐Ÿ”— CI/CD Integration ### GitHub Actions -```yaml -# .github/workflows/review.yml -name: Code Review +Using the official [cora-review composite action](.github/actions/cora-review): +```yaml +name: CI on: pull_request: - types: [opened, synchronize, reopened] + branches: [develop] jobs: - review: + cora-review: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 + - uses: ./.github/actions/cora-review + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + infisical-identity-id: ${{ secrets.INFISICAL_IDENTITY_ID }} + severity: major + upload-sarif: 'true' +``` - - name: Install cora-cli - run: cargo install cora-cli - - - name: Run code review - env: - OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - run: | - cora review --commit origin/main..HEAD \ - --output sarif \ - --output-file results.sarif +Or install manually: - - name: Upload SARIF to GitHub Code Scanning - if: always() - uses: github/codeql-action/upload-sarif@v3 - with: - sarif_file: results.sarif +```yaml +# Manual install in CI (using install script) +- name: Install cora-cli + run: curl -fsSL https://raw.githubusercontent.com/codecoradev/cora-cli/main/install.sh | sh ``` ### GitLab CI @@ -290,7 +379,7 @@ code-review: before_script: - cargo install cora-cli script: - - cora review --commit origin/main..HEAD --severity error + - cora review --base origin/main --severity major variables: OPENAI_API_KEY: $CI_OPENAI_API_KEY rules: @@ -317,7 +406,7 @@ Or add it manually to `.git/hooks/pre-commit`: ```bash #!/bin/sh # cora-cli pre-commit hook -cora review --quiet --severity error +cora review --quiet --severity major if [ $? -ne 0 ]; then echo "โŒ Code review found critical issues. Commit blocked." echo " Run 'cora review' to see details, or use 'git commit --no-verify' to skip." @@ -327,15 +416,7 @@ fi ### With [pre-commit](https://pre-commit.com) framework -```yaml -# .pre-commit-config.yaml -repos: - - repo: https://github.com/ajianaz/cora-cli - rev: v0.1.0 - hooks: - - id: cora-review - args: ['--severity', 'warning'] -``` +> ๐Ÿšง Planned โ€” the pre-commit hook repo will be available soon. For now, use `cora hook install` directly. ## ๐Ÿ†š Positioning: How Cora Compares @@ -360,6 +441,8 @@ repos: ## ๐Ÿ› ๏ธ Development +Requires **Rust 1.85+**. + ```bash # Build cargo build diff --git a/assets/cora-logo.png b/assets/cora-logo.png new file mode 100644 index 00000000..3253c16f Binary files /dev/null and b/assets/cora-logo.png differ diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 00000000..3253c16f Binary files /dev/null and b/assets/icon.png differ diff --git a/assets/logo.png b/assets/logo.png new file mode 100644 index 00000000..3253c16f Binary files /dev/null and b/assets/logo.png differ diff --git a/docs/CONFIG-REDESIGN.md b/docs/CONFIG-REDESIGN.md new file mode 100644 index 00000000..0300e142 --- /dev/null +++ b/docs/CONFIG-REDESIGN.md @@ -0,0 +1,166 @@ +# Config System Redesign: Global + Project Config + +## Status: Draft โ€” Pending Approval + +## Problem + +Current `cora config set` writes to `~/.cora/config.toml` but the config loader only reads `.cora.yaml`. Values set via `config set` are silently ignored. Additionally, there's no way to set global defaults across all projects. + +## Goal + +Support both global and per-project configuration with clear priority: + +``` +CLI flags โ†’ CORA_* env vars โ†’ .cora.yaml (project) โ†’ ~/.cora/config.yaml (global) โ†’ Defaults +``` + +## Design + +### Config Sources + +| Source | Path | Format | Scope | Priority | +|--------|------|--------|-------|----------| +| **Global** | `~/.cora/config.yaml` | YAML | All projects | 4 (lowest, after env) | +| **Project** | `.cora.yaml` (walk parent dirs) | YAML | Current project | 3 | +| **Env vars** | `CORA_PROVIDER`, `CORA_MODEL`, `CORA_BASE_URL`, `CORA_API_KEY`, etc. | โ€” | Current shell | 2 | +| **CLI flags** | `--provider`, `--model`, `--base-url`, etc. | โ€” | Current command | 1 (highest) | + +### Merge Strategy + +Same as current `.cora.yaml` merge โ€” global config is loaded first (into defaults), then project config overwrites any fields present. Each field is independently resolved. + +Example: + +**Global** (`~/.cora/config.yaml`): +```yaml +provider: + provider: openai + model: gpt-4o-mini +output: + format: compact +``` + +**Project** (`.cora.yaml`): +```yaml +provider: + model: glm-5.1 + base_url: http://litellm:4000 +hook: + mode: block +``` + +**Resolved:** +```yaml +provider: + provider: openai # from global + model: glm-5.1 # project overrides + base_url: http://litellm:4000 # project +output: + format: compact # from global +hook: + mode: block # from project + min_severity: major # default +``` + +### `cora config set` Redesign + +```bash +# Default: write to project .cora.yaml (must be inside a git project) +cora config set model glm-5.1 +cora config set base_url http://litellm:4000 + +# Explicit global +cora config set --global model gpt-4o-mini +cora config set --global provider openai + +# All supported keys: +# model, provider, base_url, format, severity (maps to hook.min_severity) +``` + +Behavior: +- **No `--global`**: writes to `.cora.yaml` in current dir (creates if missing) +- **`--global`**: writes to `~/.cora/config.yaml` (creates if missing) +- **`--global --show`**: prints global config path + contents +- Works with the same TOMLโ†’YAML key mapping as before + +### Auth File (Separate Concern) + +API key storage stays separate at `~/.cora/auth.toml` (rename from `config.toml`): + +- `cora auth login` โ†’ `~/.cora/auth.toml` (contains only `api_key`) +- `cora auth status` โ†’ reads from `~/.cora/auth.toml` +- `cora auth remove` โ†’ deletes `~/.cora/auth.toml` + +This separation means: +- Config = `~/.cora/config.yaml` (global settings, no secrets) +- Auth = `~/.cora/auth.toml` (API key only, permissions 0o600) + +### `cora config show` Enhancement + +```bash +$ cora config show + +โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•— +โ•‘ Current Configuration โ•‘ +โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + + Config sources (priority โ†“): + CLI flags โ€” + Env vars โ€” + Project config .cora.yaml โœ… + Global config ~/.cora/config.yaml โœ… + +provider: + provider: openai (global) + model: glm-5.1 (project) + base_url: http://litellm:4000 (project) + +focus: + security, performance, bugs (default) + +hook: + mode: block (project) + min_severity: major (default) + max_diff_size: 51200 (default) + +output: + format: compact (global) + color: true (default) +``` + +Each value shows its source โ€” makes debugging config priority transparent. + +## Files to Change + +| File | Change | +|------|--------| +| `src/config/loader.rs` | Add `load_global_config()` โ†’ reads `~/.cora/config.yaml`. Update `load_config()` to merge: global โ†’ project โ†’ CLI. Rename `AUTH_FILENAME` to `auth.toml`. | +| `src/commands/config_cmd.rs` | Rewrite `execute_config_set()`: write YAML instead of TOML. Add `--global` flag support. Add `base_url` key. Keep `cora config show` but add source annotations. | +| `src/main.rs` | Add `--global` flag to `config set` subcommand. Add `config set base_url `. | +| `src/config/schema.rs` | No changes needed โ€” `CoraFile` schema already supports all fields. Global uses same `CoraFile` struct. | + +## Migration + +- **No breaking change** โ€” existing `.cora.yaml` files work as-is +- `~/.cora/config.toml` (if exists) โ†’ auto-migrate to `~/.cora/config.yaml` on first run (read TOML, write YAML, delete TOML). For auth keys: rename `config.toml` โ†’ `auth.toml` +- `cora auth login` switches from writing `config.toml` to `auth.toml` + +## Effort Estimate + +| Item | LOC (est.) | Effort | +|------|-----------|--------| +| Global config loader | ~40 | 30 min | +| `config set` rewrite (YAML + `--global` + `base_url`) | ~80 | 1h | +| `config show` source annotations | ~40 | 30 min | +| Auth file rename (`config.toml` โ†’ `auth.toml`) | ~20 | 15 min | +| Migration logic (TOML โ†’ YAML) | ~30 | 30 min | +| Tests | ~60 | 45 min | +| **Total** | ~270 | **~3.5h** | + +## Risks + +| Risk | Mitigation | +|------|-----------| +| YAML serialization changes formatting | Use `serde_yaml_ng` with consistent output | +| Migration edge case (partial TOML) | Log warning, skip gracefully, user can manually edit | +| Two config sources confuse users | `config show` annotates source for each value | diff --git a/docs/FEATURE-DESIGN.md b/docs/FEATURE-DESIGN.md new file mode 100644 index 00000000..3bab445d --- /dev/null +++ b/docs/FEATURE-DESIGN.md @@ -0,0 +1,167 @@ +# Feature Design: `cora init --interactive` & `cora doctor` + +## Status: Draft โ€” Pending Review + +## Overview + +Two new features to improve developer experience: + +1. **`cora init --interactive`** (or `cora init -i`) โ€” guided, step-by-step config creation with prompts +2. **`cora doctor`** โ€” health check command that validates API connectivity, config, and environment + +--- + +## 1. `cora init --interactive` + +### Current Behavior + +`cora init` generates a static `.cora.yaml` template with hardcoded defaults (openai, gpt-4o-mini). User must manually edit the file to customize. + +CLI flags (`--provider`, `--model`, `--base-url`, `--api-key`) allow one-shot config but are non-interactive. + +### Proposed Behavior + +With `--interactive` (or `-i`), `cora init` becomes a guided wizard: + +``` +$ cora init -i + +๐Ÿš€ Welcome to Cora! Let's set up your project. + +? Select a provider: + > OpenAI + Anthropic + Groq + Ollama (local) + Google + Custom (OpenAI-compatible) + +? Model name (default: gpt-4o-mini): glm-5.1 + +? Base URL (default: https://api.openai.com/v1): https://litellm:4000 + +? API key: **** (or leave blank to use CORA_API_KEY env var) + +? Review focus areas (space to toggle, enter to confirm): + [โœ“] security + [โœ“] performance + [ ] bugs + [ ] best_practice + [ ] style + [ ] maintainability + +? Pre-commit hook mode: + > warn (print findings but allow commit) + block (fail commit on findings above threshold) + +โœ… Created .cora.yaml with your settings. +โœ… Pre-commit hook installed. + +Next steps: + cora review # Review staged changes + cora scan # Scan the full project +``` + +### Implementation Approach + +- **No new dependencies** โ€” use `dialoguer` (already in Cargo.toml? check) or `inquire`. If neither exists, use minimal stdin/stdout with `rustyline` or basic `io::stdin()`. +- **Fallback** โ€” if `--interactive` is not passed, current behavior is preserved (static template). +- **Flags take precedence** โ€” `cora init -i --provider openai --model gpt-4o` skips the provider/model prompts. + +### Config Schema Changes + +None โ€” output is the same `.cora.yaml` format, just populated interactively. + +### Open Questions + +1. **Which prompt library?** โ€” `dialoguer` (lightweight), `inquire` (feature-rich), or raw stdin? +2. **Should it test API connectivity** after setup? (e.g., send a minimal request to validate key) +3. **Should it detect existing API keys** from env vars and pre-select the provider? + +--- + +## 2. `cora doctor` + +### Purpose + +Diagnostic command that checks the entire cora setup โ€” config, API connectivity, git state, environment. + +### Proposed Output + +``` +$ cora doctor + +๐Ÿ” Cora Doctor โ€” System Health Check + +Config + โœ… .cora.yaml found at /project/.cora.yaml + โœ… Config schema valid + +API Connectivity + โœ… Provider: openai (gpt-4o-mini) + โœ… API key detected (CORA_API_KEY) + โœ… Connection OK โ€” 142ms latency + โœ… Model accessible + +Git + โœ… Inside a git repository + โœ… Upstream detected: origin/main + โš ๏ธ 3 uncommitted changes detected + +Environment + โœ… Rust version: 1.85.0 + โœ… cora version: 0.1.2 + โœ… Shell completions available: bash, zsh, fish, powershell + +Pre-commit Hook + โœ… Installed at .git/hooks/pre-commit + +Result: 7 passed, 1 warning โ€” your setup is ready! ๐Ÿš€ +``` + +### Checks (ordered) + +| # | Check | Severity | What it does | +|---|-------|----------|-------------| +| 1 | Config file found | error | Looks for `.cora.yaml` or `~/.config/cora/config.yaml` | +| 2 | Config schema valid | error | Parses YAML, validates against schema | +| 3 | API key available | error | Checks `CORA_API_KEY` or provider-specific env vars | +| 4 | Provider configured | error | Has a valid provider name | +| 5 | API connectivity | error | Sends a minimal request (e.g., list models) to validate key + endpoint | +| 6 | API latency | info | Measures round-trip time to API endpoint | +| 7 | Model accessible | warn | Validates the configured model exists at the endpoint | +| 8 | Git repository | warn | Checks if inside a git repo (not required for `cora scan --path`) | +| 9 | Pre-commit hook | info | Checks if `.git/hooks/pre-commit` has cora | +| 10 | Cora version | info | Shows current version, checks for updates | + +### Implementation Approach + +- **New command** โ€” `src/commands/doctor.rs` +- **New subcommand** in `main.rs` CLI definition +- **Check result type** โ€” enum `{ Ok, Warning, Error }` with message +- **Optional `--json` flag** โ€” machine-readable output for CI +- **Exit code** โ€” 0 if all pass, 1 if any error, 2 if connection failed + +### Open Questions + +1. **Check for updates** โ€” should `cora doctor` hit GitHub API to check latest version? (adds network dependency) +2. **Model validation** โ€” how to check if a model exists without expensive API calls? (Some providers support `GET /models`) +3. **Should it fix things?** โ€” e.g., `cora doctor --fix` to auto-install hook, fix config schema? + +--- + +## Effort Estimate + +| Feature | LOC (est.) | New Files | Effort | Risk | +|---------|-----------|-----------|--------|------| +| `cora init -i` | ~150-250 | 1 (modify `init.rs`) | 2-3h | Low | +| `cora doctor` | ~200-300 | 2 (`doctor.rs`, types) | 3-4h | Low | + +## Dependencies + +- `dialoguer` or `inquire` crate (for interactive prompts) โ€” whichever is lighter +- No new deps for `cora doctor` โ€” uses existing HTTP client + +## Timeline + +Both features can go into **v0.2.0** (next minor release) or as individual patches. diff --git a/install.sh b/install.sh new file mode 100755 index 00000000..63f95a83 --- /dev/null +++ b/install.sh @@ -0,0 +1,195 @@ +#!/usr/bin/env sh +# cora installer โ€” https://github.com/codecoradev/cora-cli +# Usage: curl -fsSL https://raw.githubusercontent.com/codecoradev/cora-cli/main/install.sh | sh + +set -e + +REPO="codecoradev/cora-cli" +BINARY_NAME="cora" +INSTALL_DIR="${CORA_INSTALL_DIR:-$HOME/.local/bin}" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +info() { + printf "${GREEN}[INFO]${NC} %s\n" "$1" +} + +warn() { + printf "${YELLOW}[WARN]${NC} %s\n" "$1" +} + +error() { + printf "${RED}[ERROR]${NC} %s\n" "$1" + exit 1 +} + +# Detect OS +detect_os() { + case "$(uname -s)" in + Linux*) OS="linux";; + Darwin*) OS="darwin";; + *) error "Unsupported operating system: $(uname -s)";; + esac +} + +# Detect architecture +detect_arch() { + case "$(uname -m)" in + x86_64|amd64) ARCH="x86_64";; + arm64|aarch64) ARCH="aarch64";; + *) error "Unsupported architecture: $(uname -m)";; + esac +} + +# Get latest release version +# Primary: parse the 302 redirect on /releases/latest (no API call, no rate limit). +# Fallback: the GitHub REST API (subject to 60 req/hour anonymous limit). +get_latest_version() { + # Try the web redirect first โ€” does not count against the API rate limit. + VERSION=$(curl -sI "https://github.com/${REPO}/releases/latest" \ + | grep -i '^location:' \ + | sed -E 's|.*/tag/([^[:space:]]+).*|\1|' \ + | tr -d '\r') + + # Fallback to the REST API if the redirect didn't yield a tag. + if [ -z "$VERSION" ]; then + warn "Redirect lookup failed, falling back to GitHub API..." + VERSION=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" \ + | grep '"tag_name":' \ + | sed -E 's/.*"([^"]+)".*/\1/') + fi + + if [ -z "$VERSION" ]; then + error "Failed to get latest version. Set CORA_VERSION=vX.Y.Z to pin a version." + fi +} + +# Build target triple and archive name +get_target() { + case "$OS" in + linux) + case "$ARCH" in + x86_64) TARGET="x86_64-unknown-linux-gnu";; + aarch64) TARGET="aarch64-unknown-linux-gnu";; + esac + ;; + darwin) + # Only aarch64 (Apple Silicon) is currently published + if [ "$ARCH" != "aarch64" ]; then + warn "No pre-built binary for x86_64 macOS. Install via cargo:" + warn " cargo install --git https://github.com/${REPO}" + exit 0 + fi + TARGET="aarch64-apple-darwin" + ;; + esac +} + +# Download and install +install() { + info "Detected: $OS $ARCH" + info "Target: $TARGET" + info "Version: $VERSION" + + ARCHIVE_NAME="${BINARY_NAME}-${TARGET}-${VERSION}.tar.gz" + DOWNLOAD_URL="https://github.com/${REPO}/releases/download/${VERSION}/${ARCHIVE_NAME}" + TEMP_DIR=$(mktemp -d) + ARCHIVE="${TEMP_DIR}/${ARCHIVE_NAME}" + + CHECKSUMS_URL="https://github.com/${REPO}/releases/download/${VERSION}/checksums-sha256.txt" + CHECKSUM_FILE="${TEMP_DIR}/checksums-sha256.txt" + + info "Downloading from: $DOWNLOAD_URL" + if ! curl -fsSL "$DOWNLOAD_URL" -o "$ARCHIVE"; then + error "Failed to download ${ARCHIVE_NAME}" + fi + + # Verify SHA256 checksum (prevents MITM / corrupted download). + info "Downloading checksums..." + if curl -fsSL "$CHECKSUMS_URL" -o "$CHECKSUM_FILE"; then + info "Verifying SHA256 checksum..." + EXPECTED=$(grep -F "$ARCHIVE_NAME" "$CHECKSUM_FILE" | awk '{print $1}') + if [ -n "$EXPECTED" ]; then + ACTUAL=$(sha256sum "$ARCHIVE" | awk '{print $1}') + if [ "$ACTUAL" != "$EXPECTED" ]; then + error "Checksum mismatch! Expected: ${EXPECTED}, got: ${ACTUAL}" + fi + info "Checksum verified: $EXPECTED" + else + warn "Checksum for ${ARCHIVE_NAME} not found in checksums file โ€” skipping verification" + fi + else + warn "Failed to download checksums โ€” skipping verification" + fi + + # Verify archive contents before extraction (CWE-22 path traversal). + # Reject any entry with an absolute path or a ".." component. + info "Verifying archive integrity..." + if tar -tzf "$ARCHIVE" | grep -qE '^/|(^|/)\.\.(/|$)'; then + error "Archive contains unsafe paths (absolute or directory traversal) โ€” refusing to extract" + fi + + info "Extracting..." + tar -xzf "$ARCHIVE" -C "$TEMP_DIR" + + mkdir -p "$INSTALL_DIR" + mv "${TEMP_DIR}/${BINARY_NAME}" "${INSTALL_DIR}/" + + chmod +x "${INSTALL_DIR}/${BINARY_NAME}" + + # Cleanup + rm -rf "$TEMP_DIR" + + info "Successfully installed ${BINARY_NAME} to ${INSTALL_DIR}/${BINARY_NAME}" +} + +# Verify installation +verify() { + if command -v "$BINARY_NAME" >/dev/null 2>&1; then + INSTALLED_VERSION=$("$BINARY_NAME" --version 2>/dev/null || echo "unknown") + info "Verification: $INSTALLED_VERSION" + else + warn "Binary installed but not in PATH. Add to your shell profile:" + case "${SHELL:-}" in + */zsh) + warn ' echo '\''export PATH="$HOME/.local/bin:$PATH"'\'' >> ~/.zshrc' + warn ' source ~/.zshrc' + ;; + */bash) + warn ' echo '\''export PATH="$HOME/.local/bin:$PATH"'\'' >> ~/.bashrc' + warn ' source ~/.bashrc' + ;; + */fish) + warn ' fish_add_path ~/.local/bin' + ;; + *) + warn ' export PATH="$HOME/.local/bin:$PATH"' + ;; + esac + fi +} + +main() { + info "Installing ${BINARY_NAME}..." + + detect_os + detect_arch + get_target + if [ -n "$CORA_VERSION" ]; then + VERSION="$CORA_VERSION" + info "Using pinned version from CORA_VERSION: $VERSION" + else + get_latest_version + fi + install + verify + + echo "" + info "Installation complete! Run '${BINARY_NAME} --help' to get started." +} + +main diff --git a/src/commands/auth.rs b/src/commands/auth.rs index 58f1b9f3..36548224 100644 --- a/src/commands/auth.rs +++ b/src/commands/auth.rs @@ -38,15 +38,7 @@ pub fn execute_auth_login() -> Result<()> { } loader::save_api_key(&key)?; - println!( - "{} API key saved to {}", - "โœ…".green().bold(), - dirs::config_dir() - .unwrap_or_default() - .join("cora") - .join("config.toml") - .display() - ); + println!("{} API key saved to ~/.cora/auth.toml", "โœ…".green().bold()); println!( "{}", " This file is local to your machine and not committed to git.".dimmed() @@ -60,16 +52,10 @@ pub fn execute_auth_status() -> Result<()> { let status = loader::auth_status()?; if status.has_key { - println!( - "{} API key is configured.", - "โœ…".green().bold() - ); + println!("{} API key is configured.", "โœ…".green().bold()); println!(" Source: {}", status.source); } else { - println!( - "{} No API key configured.", - "โŒ".red().bold() - ); + println!("{} No API key configured.", "โŒ".red().bold()); println!(" Set it via:"); println!(" โ€ข CORA_API_KEY environment variable"); println!(" โ€ข `cora auth login` command"); @@ -83,18 +69,12 @@ pub fn execute_auth_status() -> Result<()> { pub fn execute_auth_remove() -> Result<()> { let status = loader::auth_status()?; if !status.has_key && std::env::var("CORA_API_KEY").is_err() { - println!( - "{}", - "No API key found to remove.".yellow() - ); + println!("{}", "No API key found to remove.".yellow()); return Ok(()); } loader::remove_api_key()?; - println!( - "{} API key removed from local config.", - "โœ…".green().bold() - ); + println!("{} API key removed from local config.", "โœ…".green().bold()); println!( "{}", " If you set CORA_API_KEY in your shell, remove it there too.".dimmed() diff --git a/src/commands/completion.rs b/src/commands/completion.rs new file mode 100644 index 00000000..8acf83c0 --- /dev/null +++ b/src/commands/completion.rs @@ -0,0 +1,16 @@ +use anyhow::Result; +use clap::CommandFactory; +use clap_complete::aot::{Shell, generate}; + +use crate::Cli; + +pub fn execute_completion(shell: &str) -> Result { + let shell: Shell = shell.parse().map_err(|_| { + anyhow::anyhow!("Invalid shell: {shell}. Supported shells: bash, zsh, fish, powershell") + })?; + + let mut cmd = Cli::command(); + generate(shell, &mut cmd, "cora", &mut std::io::stdout()); + + Ok(0) +} diff --git a/src/commands/config_cmd.rs b/src/commands/config_cmd.rs new file mode 100644 index 00000000..01643640 --- /dev/null +++ b/src/commands/config_cmd.rs @@ -0,0 +1,423 @@ +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use colored::Colorize; + +use crate::config::loader; +use crate::config::schema::{CoraFile, HookSection, OutputSection, ProviderSection}; + +/// Execute `cora config show` โ€” print the current resolved configuration. +pub fn execute_config_show() -> Result<()> { + let config = loader::load_config(None, None, None, None, None, false)?; + + println!( + "{}", + "โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—".cyan().bold() + ); + println!( + "{}", + "โ•‘ Current Configuration โ•‘" + .cyan() + .bold() + ); + println!( + "{}", + "โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•".cyan().bold() + ); + println!(); + + println!( + "{} {}", + "provider:".bold(), + config.provider.provider.green() + ); + println!("{} {}", " model:".dimmed(), config.provider.model.green()); + println!( + "{} {}", + " base_url:".dimmed(), + config.provider.base_url.green() + ); + + println!( + "{} {}", + "focus:".bold(), + config + .focus + .iter() + .map(|f| format!("{}", f.green())) + .collect::>() + .join(", ") + ); + + if !config.rules.is_empty() { + println!( + "{} {}", + "rules:".bold(), + config + .rules + .iter() + .map(|r| format!("{}", r.green())) + .collect::>() + .join(", ") + ); + } + + println!( + "{} mode={} min_severity={} max_diff_size={}", + "hook:".bold(), + config.hook.mode.yellow(), + config.hook.min_severity.yellow(), + config.hook.max_diff_size + ); + + println!( + "{} format={} color={}", + "output:".bold(), + config.output.format.green(), + config.output.color + ); + + println!( + "{} {}", + "ignore files:".bold(), + if config.ignore.files.is_empty() { + "(none)".to_string().dimmed().to_string() + } else { + config + .ignore + .files + .iter() + .map(|f| format!("{}", f.dimmed())) + .collect::>() + .join(", ") + } + ); + + Ok(()) +} + +/// Execute `cora config set [--global] ` โ€” write a key-value pair +/// to a YAML config file. +/// +/// Supported keys: model, provider, `base_url`, format, severity +pub fn execute_config_set(key: &str, value: &str, global: bool) -> Result<()> { + // Validate the key + match key { + "model" | "provider" | "base_url" | "format" | "severity" => {} + _ => { + anyhow::bail!( + "unsupported key: {key}\nSupported keys: model, provider, base_url, format, severity" + ); + } + } + + // Determine target path + let path = if global { + let dir = loader::cora_dir()?; + std::fs::create_dir_all(&dir) + .with_context(|| format!("failed to create {}", dir.display()))?; + dir.join("config.yaml") + } else { + // Warn if not inside a git project (could create orphan file) + if std::process::Command::new("git") + .args(["rev-parse", "--git-dir"]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .is_ok_and(|s| s.success()) + { + PathBuf::from(".cora.yaml") + } else { + anyhow::bail!( + "Not in a git project. Use --global to set config in ~/.cora/config.yaml, or cd into a git repo first." + ); + } + }; + + // Load existing file or start fresh + let mut cora = if path.is_file() { + let content = std::fs::read_to_string(&path) + .with_context(|| format!("failed to read {}", path.display()))?; + CoraFile::from_str(&content).unwrap_or_default() + } else { + CoraFile::default() + }; + + // Apply the key update + match key { + "model" => { + let provider = cora.provider.get_or_insert_with(ProviderSection::default); + provider.model = Some(value.to_string()); + } + "provider" => { + let provider = cora.provider.get_or_insert_with(ProviderSection::default); + provider.provider = Some(value.to_string()); + } + "base_url" => { + let provider = cora.provider.get_or_insert_with(ProviderSection::default); + provider.base_url = Some(value.to_string()); + } + "format" => { + let output = cora.output.get_or_insert_with(OutputSection::default); + output.format = Some(value.to_string()); + } + "severity" => { + let hook = cora.hook.get_or_insert_with(HookSection::default); + hook.min_severity = Some(value.to_string()); + } + _ => unreachable!(), + } + + // Write back as YAML + let yaml = serde_yaml_ng::to_string(&cora).context("failed to serialize config to YAML")?; + std::fs::write(&path, &yaml).with_context(|| format!("failed to write {}", path.display()))?; + + // Restrict permissions for global config (defense-in-depth) + #[cfg(unix)] + if global { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)); + } + + let scope = if global { "global" } else { "project" }; + println!( + "{} Set {} = {} in {} ({})", + "โœ“".green().bold(), + key.bold(), + value.green(), + path.display(), + scope.dimmed() + ); + + Ok(()) +} + +/// Execute `cora config validate` โ€” load config and report validity. +/// +/// Returns exit code: 0 if valid, 2 if issues found. +pub fn execute_config_validate() -> Result { + // 1. Load resolved config (same as other commands) + let config = loader::load_config(None, None, None, None, None, false)?; + + // 2. Find the raw config file to check which fields were explicitly set + let cora_file = loader::find_cora_file(&std::env::current_dir().unwrap_or_default()) + .unwrap_or_else(|e| { + eprintln!( + "{} Warning: could not search for config file: {}", + "โš ๏ธ".yellow(), + e + ); + None + }); + + // Also check global config for fields set there + let global_cora = load_global_cora_file(); + + // Track how many fields were explicitly set + let mut explicitly_set = 0u32; + let total_fields = 8u32; + + // โ”€โ”€ Check config file โ”€โ”€ + match &cora_file { + Some((path, _)) => { + println!("{} Config file: {}", "โœ…".green().bold(), path.display()); + explicitly_set += 1; + } + None => { + println!( + "{} Config file: not found (using defaults)", + "โš ๏ธ".yellow().bold() + ); + } + } + + // โ”€โ”€ Check provider โ”€โ”€ + let provider_set = is_explicitly_set( + &cora_file, + &global_cora, + |c| c.provider.as_ref(), + |g| g.provider.as_ref(), + ); + if provider_set { + println!( + "{} Provider: {}", + "โœ…".green().bold(), + config.provider.provider + ); + explicitly_set += 1; + } else { + println!( + "{} Provider: not set (default: {})", + "โš ๏ธ".yellow().bold(), + config.provider.provider + ); + } + + // โ”€โ”€ Check model โ”€โ”€ + let model_set = is_explicitly_set( + &cora_file, + &global_cora, + |c| c.provider.as_ref().and_then(|p| p.model.as_ref()), + |g| g.provider.as_ref().and_then(|p| p.model.as_ref()), + ); + if model_set { + println!("{} Model: {}", "โœ…".green().bold(), config.provider.model); + explicitly_set += 1; + } else { + println!( + "{} Model: not set (default: {})", + "โš ๏ธ".yellow().bold(), + config.provider.model + ); + } + + // โ”€โ”€ Check API key โ”€โ”€ + let auth = loader::auth_status().context("failed to check auth status")?; + if auth.has_key { + let source = if auth.source.contains("env") { + "env var".to_string() + } else { + format!("auth file ({})", auth.source) + }; + println!("{} API key: configured ({})", "โœ…".green().bold(), source); + explicitly_set += 1; + } else { + println!("{} API key: not configured", "โŒ".red().bold()); + } + + // โ”€โ”€ Check severity โ”€โ”€ + let severity_set = is_explicitly_set( + &cora_file, + &global_cora, + |c| c.hook.as_ref().and_then(|h| h.min_severity.as_ref()), + |g| g.hook.as_ref().and_then(|h| h.min_severity.as_ref()), + ); + if severity_set { + println!( + "{} Severity: {}", + "โœ…".green().bold(), + config.hook.min_severity + ); + explicitly_set += 1; + } else { + println!( + "{} Severity: not set (default: {})", + "โš ๏ธ".yellow().bold(), + config.hook.min_severity + ); + } + + // โ”€โ”€ Check output format โ”€โ”€ + let format_set = is_explicitly_set( + &cora_file, + &global_cora, + |c| c.output.as_ref().and_then(|o| o.format.as_ref()), + |g| g.output.as_ref().and_then(|o| o.format.as_ref()), + ); + if format_set { + println!( + "{} Output format: {}", + "โœ…".green().bold(), + config.output.format + ); + explicitly_set += 1; + } else { + println!( + "{} Output format: not set (default: {})", + "โš ๏ธ".yellow().bold(), + config.output.format + ); + } + + // โ”€โ”€ Check temperature โ”€โ”€ + let temp_set = is_explicitly_set( + &cora_file, + &global_cora, + |c| c.llm.as_ref().and_then(|l| l.temperature.as_ref()), + |g| g.llm.as_ref().and_then(|l| l.temperature.as_ref()), + ); + if temp_set { + println!( + "{} Temperature: {}", + "โœ…".green().bold(), + config.temperature + ); + explicitly_set += 1; + } else { + println!( + "{} Temperature: not set (default: {})", + "โš ๏ธ".yellow().bold(), + config.temperature + ); + } + + // โ”€โ”€ Check cache TTL โ”€โ”€ + let cache_set = is_explicitly_set( + &cora_file, + &global_cora, + |c| c.llm.as_ref().and_then(|l| l.cache_ttl.as_ref()), + |g| g.llm.as_ref().and_then(|l| l.cache_ttl.as_ref()), + ); + if cache_set { + println!("{} Cache TTL: {}", "โœ…".green().bold(), config.cache_ttl); + explicitly_set += 1; + } else { + println!( + "{} Cache TTL: not set (default: {})", + "โš ๏ธ".yellow().bold(), + config.cache_ttl + ); + } + + println!(); + + // Determine overall validity + let has_issues = !auth.has_key; + if has_issues { + println!( + "{}", + "Configuration issues found. Fix the โŒ items above." + .red() + .bold() + ); + println!( + "{}", + format!("{}/{} fields explicitly set.", explicitly_set, total_fields).dimmed() + ); + Ok(2) + } else { + println!("{}", "Configuration valid.".green().bold()); + println!( + "{}", + format!("{}/{} fields explicitly set.", explicitly_set, total_fields).dimmed() + ); + Ok(0) + } +} + +/// Check if a field was explicitly set in either the project config or global config. +/// Uses closure extractors to check both config sources. +fn is_explicitly_set( + project: &Option<(PathBuf, CoraFile)>, + global: &Option, + project_extractor: impl Fn(&CoraFile) -> Option<&T>, + global_extractor: impl Fn(&CoraFile) -> Option<&T>, +) -> bool { + let project_has = project + .as_ref() + .and_then(|(_, c)| project_extractor(c)) + .is_some(); + let global_has = global.as_ref().and_then(global_extractor).is_some(); + project_has || global_has +} + +/// Load the global config file (same logic as loader::load_global_config but returns Option directly). +fn load_global_cora_file() -> Option { + let dir = loader::cora_dir().ok()?; + let path = dir.join("config.yaml"); + if !path.is_file() { + return None; + } + let content = std::fs::read_to_string(&path).ok()?; + CoraFile::from_str(&content).ok() +} diff --git a/src/commands/hook_cmd.rs b/src/commands/hook_cmd.rs index ad4e8e6a..a1c07198 100644 --- a/src/commands/hook_cmd.rs +++ b/src/commands/hook_cmd.rs @@ -16,10 +16,7 @@ pub fn execute_hook_install() -> Result<()> { "{}", " The hook will run `cora review --staged --format compact` before each commit.".dimmed() ); - println!( - "{}", - " Use `cora hook uninstall` to remove.".dimmed() - ); + println!("{}", " Use `cora hook uninstall` to remove.".dimmed()); Ok(()) } @@ -28,10 +25,7 @@ pub fn execute_hook_install() -> Result<()> { pub fn execute_hook_uninstall() -> Result<()> { hook::uninstall_hook()?; - println!( - "{} Pre-commit hook removed.", - "โœ…".green().bold() - ); + println!("{} Pre-commit hook removed.", "โœ…".green().bold()); Ok(()) } diff --git a/src/commands/init.rs b/src/commands/init.rs index 05ee31e0..f2786368 100644 --- a/src/commands/init.rs +++ b/src/commands/init.rs @@ -4,7 +4,7 @@ use tracing::debug; /// Example .cora.yaml template content. const CONFIG_TEMPLATE: &str = r#"# Cora Code Review Configuration -# See: https://github.com/ajianaz/cora-cli +# See: https://github.com/codecoradev/cora-cli # Provider settings provider: diff --git a/src/commands/mod.rs b/src/commands/mod.rs index f552be1d..7b0c7979 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,6 +1,9 @@ pub mod auth; +pub mod completion; +pub mod config_cmd; pub mod hook_cmd; pub mod init; +pub mod providers; pub mod review; pub mod scan; - +pub mod upload; diff --git a/src/commands/providers.rs b/src/commands/providers.rs new file mode 100644 index 00000000..4d4302ca --- /dev/null +++ b/src/commands/providers.rs @@ -0,0 +1,71 @@ +use colored::Colorize; + +use crate::config::providers::{PRESETS, detected_presets}; + +/// Execute the `cora providers` subcommand. +pub fn execute_providers() { + let detected = detected_presets(); + + println!("{}", "Available LLM Providers".bold().underline()); + println!(); + + for preset in PRESETS { + let is_detected = detected.iter().any(|d| d.name == preset.name); + let status = if is_detected { + "โœ“".green().to_string() + } else { + "โœ—".dimmed().to_string() + }; + + println!(" {} {}", status, preset.name.bold()); + + if is_detected { + println!(" {} {} (detected)", "key:".dimmed(), preset.env_key); + } else { + println!(" {} set {} to enable", "key:".dimmed(), preset.env_key); + } + + println!(" {} {}", "model:".dimmed(), preset.default_model); + println!(" {} {}", "base:".dimmed(), preset.default_base_url); + println!( + " {} {} = ", + "url override:".dimmed(), + preset.env_url + ); + println!(); + } + + // Summary + if detected.is_empty() { + println!( + "{} No providers detected. Set an API key env var from the list above.", + "โš ๏ธ".yellow() + ); + println!( + "{}", + " Or set CORA_API_KEY with --provider to use any OpenAI-compatible endpoint." + .dimmed() + ); + } else if detected.len() == 1 { + println!( + "{} Auto-detected provider: {}", + "โœ“".green(), + detected[0].name.bold() + ); + println!( + "{}", + " Set CORA_PROVIDER or use --provider to override.".dimmed() + ); + } else { + let names: Vec<&str> = detected.iter().map(|d| d.name).collect(); + println!( + "{} Multiple providers detected: {}", + "โ„น๏ธ".cyan(), + names.join(", ").bold() + ); + println!( + "{}", + " Using first detected. Set CORA_PROVIDER or use --provider to choose.".dimmed() + ); + } +} diff --git a/src/commands/review.rs b/src/commands/review.rs index d97c2234..1f41f6e9 100644 --- a/src/commands/review.rs +++ b/src/commands/review.rs @@ -1,17 +1,19 @@ -use anyhow::Result; +use anyhow::{Context, Result}; use colored::Colorize; use tracing::debug; use crate::config::schema::Config; -use crate::formatters::{formatter_for, OutputFormat}; +use crate::engine::Severity; +use crate::formatters::{OutputFormat, formatter_for}; use crate::git; +use crate::progress::{ProgressReporter, TokenInfo, diff_stats}; /// Exit codes for the review command. pub const EXIT_OK: i32 = 0; -pub const EXIT_ERROR: i32 = 1; pub const EXIT_BLOCKED: i32 = 2; /// Review command options. +#[allow(clippy::struct_excessive_bools)] pub struct ReviewOptions { /// Review staged changes. pub staged: bool, @@ -19,33 +21,90 @@ pub struct ReviewOptions { pub unpushed: bool, /// Review diff against a base branch. pub base: Option, + /// Review diff from a git commit reference (e.g. HEAD, HEAD~3..HEAD, abc123). + pub commit: Option, + /// Read diff from a file instead of git. + pub diff_file: Option, /// Review unstaged changes. pub unstaged: bool, /// Maximum diff size before refusing (0 = use config default). pub max_diff_size: Option, + /// Stream LLM response tokens to stdout in real-time. + pub stream: bool, + /// Suppress all output except the formatted review result. + pub quiet: bool, + /// Minimum severity level for filtering issues. + pub severity: Option, + /// Disable review caching. + pub no_cache: bool, + /// CI mode: skip diff size limit, hard gate on any findings. + pub ci: bool, +} + +/// Result of a review command execution. +pub struct ReviewResult { + /// Exit code (0 = ok, 2 = blocked). + pub exit_code: i32, + /// The formatted output string. + pub output: String, } /// Execute the review command. /// /// Gets the diff, validates its size, calls the LLM engine, formats output, -/// and returns the appropriate exit code. +/// and returns the appropriate exit code along with the formatted output. +#[allow(clippy::cast_possible_truncation)] pub async fn execute_review( config: &Config, llm_config: &crate::engine::LLMConfig, opts: &ReviewOptions, format: OutputFormat, -) -> Result { + progress: &ProgressReporter, +) -> Result { // 1. Get the diff let diff = get_diff(opts, config)?; if diff.trim().is_empty() { - println!("{}", "No changes to review.".yellow()); - return Ok(EXIT_OK); + if opts.quiet { + return Ok(ReviewResult { + exit_code: EXIT_OK, + output: String::new(), + }); + } + let output = format!("{}\n", "No changes to review.".yellow()); + return Ok(ReviewResult { + exit_code: EXIT_OK, + output, + }); } - // 2. Validate size + // 2. Emit parsing_diff event if progress enabled + if progress.is_enabled() { + let (files_changed, lines_changed) = diff_stats(&diff); + progress.parsing_diff(files_changed, lines_changed); + } + + // 3. Validate size (skip in CI mode) let max_size = opts.max_diff_size.unwrap_or(config.hook.max_diff_size); - if diff.len() > max_size { + if !opts.ci && diff.len() > max_size { + if config.hook.on_violation == "disallow" { + // Return blocked result instead of error + return Ok(ReviewResult { + exit_code: EXIT_BLOCKED, + output: format!( + "{}\n", + format!( + "โŒ Diff too large ({} chars, max {}). Commit blocked. \ + Use --base to review a specific branch, increase \ + hook.max_diff_size, or run: git commit --no-verify", + diff.len(), + max_size + ) + .red() + .bold(), + ), + }); + } anyhow::bail!( "Diff too large ({} chars, max {}). Use --base to review a specific branch, or increase hook.max_diff_size.", diff.len(), @@ -53,41 +112,127 @@ pub async fn execute_review( ); } - debug!(diff_len = diff.len(), "running review"); + debug!( + diff_len = diff.len(), + stream = opts.stream, + "running review" + ); - // 3. Call the LLM engine - let response = crate::engine::review::review_diff(config, llm_config, &diff).await?; + // 4. Emit calling_llm event + if progress.is_enabled() { + progress.calling_llm(&llm_config.provider, &llm_config.model); + } + + let llm_start = std::time::Instant::now(); + + // 5. Call the LLM engine + let response = match crate::engine::review::review_diff_with_cache( + config, + llm_config, + &diff, + opts.stream, + !opts.no_cache, + opts.quiet, + ) + .await + { + Ok(resp) => { + if progress.is_enabled() { + // SAFETY: elapsed ms fits u64 for any reasonable review duration + let duration_ms = llm_start.elapsed().as_millis() as u64; + let tokens = resp + .tokens_used + .as_ref() + .map_or_else(TokenInfo::zero, TokenInfo::from_usage); + progress.llm_response(&tokens, duration_ms); + } + resp + } + Err(e) => { + if progress.is_enabled() { + progress.error(&e.to_string(), "calling_llm"); + } + return Err(e.into()); + } + }; - // 4. Format and print output + // 6. Filter by severity if specified + let min_severity = if let Some(ref sev) = opts.severity { + Severity::from_str_lossy(sev) + } else { + config.hook.min_severity_level() + }; + + let mut filtered_response = response.clone(); + // Keep issues at or above min_severity (Critical=worst, Info=lowest in Ord but + // Ord order is Critical(0) < Major(1) < Minor(2) < Info(3), so we invert: keep + // where severity Ord value <= min_severity Ord value). + filtered_response + .issues + .retain(|i| i.severity <= min_severity); + + // 7. Format output let formatter = formatter_for(format); - let output = formatter.format_review(&response)?; - println!("{}", output); + let output = formatter.format_review(&filtered_response)?; - // 5. Return exit code - if response.should_block && config.hook.mode == "block" { - Ok(EXIT_BLOCKED) + // 8. Return exit code + let exit_code = if opts.ci { + // CI mode: hard gate โ€” any finding blocks + if !filtered_response.issues.is_empty() { + EXIT_BLOCKED + } else { + EXIT_OK + } + } else if response.should_block && config.hook.mode == "block" { + EXIT_BLOCKED } else { - Ok(EXIT_OK) + EXIT_OK + }; + + // 9. Emit complete event + if progress.is_enabled() { + let tokens = response + .tokens_used + .as_ref() + .map_or_else(TokenInfo::zero, TokenInfo::from_usage); + progress.complete( + filtered_response.issues.len(), + exit_code == EXIT_BLOCKED, + &tokens, + ); } + + Ok(ReviewResult { exit_code, output }) } /// Get the diff based on the provided options. fn get_diff(opts: &ReviewOptions, _config: &Config) -> Result { + if let Some(ref diff_file) = opts.diff_file { + let path = std::path::Path::new(diff_file); + if !path.exists() { + anyhow::bail!("diff file not found: {diff_file}"); + } + return std::fs::read_to_string(path) + .with_context(|| format!("failed to read diff file: {diff_file}")); + } + if let Some(ref commit_ref) = opts.commit { + return git::get_commit_diff(commit_ref).map_err(Into::into); + } if opts.staged { - git::get_staged_diff() + git::get_staged_diff().map_err(Into::into) } else if opts.unpushed { - git::get_unpushed_diff() + git::get_unpushed_diff().map_err(Into::into) } else if let Some(ref base) = opts.base { - git::get_branch_diff(base) + git::get_branch_diff(base).map_err(Into::into) } else if opts.unstaged { - git::get_unstaged_diff() + git::get_unstaged_diff().map_err(Into::into) } else { // Default: try staged first, fall back to unpushed, then unstaged match git::get_staged_diff() { Ok(d) if !d.trim().is_empty() => Ok(d), _ => match git::get_unpushed_diff() { Ok(d) if !d.trim().is_empty() => Ok(d), - _ => git::get_unstaged_diff(), + _ => git::get_unstaged_diff().map_err(Into::into), }, } } diff --git a/src/commands/scan.rs b/src/commands/scan.rs index 13e0903f..c1535385 100644 --- a/src/commands/scan.rs +++ b/src/commands/scan.rs @@ -1,12 +1,12 @@ use std::path::Path; -use anyhow::Result; +use anyhow::{Context, Result}; use colored::Colorize; use tracing::debug; use crate::config::schema::Config; use crate::engine::scanner::{batch_files, format_batch_for_prompt, walk_project}; -use crate::formatters::{formatter_for, OutputFormat}; +use crate::formatters::{OutputFormat, formatter_for}; /// Scan command options. pub struct ScanOptions { @@ -18,12 +18,17 @@ pub struct ScanOptions { pub exclude: Vec, /// Additional file extensions to include. pub extensions: Vec, + /// Only scan files changed since last scan. + pub incremental: bool, + /// Focus areas for review (overrides config). + pub focus: Vec, } /// Execute the scan command. /// /// Walks the project directory, filters files, batches them, calls the LLM, /// and formats the output. +#[allow(clippy::too_many_lines)] pub async fn execute_scan( config: &Config, llm_config: &crate::engine::LLMConfig, @@ -44,20 +49,51 @@ pub async fn execute_scan( let mut exclude = config.ignore.files.clone(); exclude.extend(opts.exclude.clone()); + // Merge focus areas: CLI --focus overrides config + let effective_focus = if opts.focus.is_empty() { + config.focus.clone() + } else { + opts.focus.clone() + }; + debug!(root = %root.display(), "starting scan"); // 1. Walk and collect files - let files = walk_project(&root, &include, &exclude, &opts.extensions)?; + let mut files = walk_project(&root, &include, &exclude, &opts.extensions)?; + + // 1b. Incremental: filter out unchanged files + if opts.incremental { + let cache = ScanCache::load()?; + let before_count = files.len(); + let root_abs = root.canonicalize().unwrap_or_else(|_| root.clone()); + files.retain(|f| { + let abs_path = root_abs.join(&f.path); + let Some(hash) = file_content_hash(&abs_path) else { + return true; // can't read file, rescan it + }; + match cache.get(&root_abs, &f.path) { + Some(cached_hash) if cached_hash == hash => { + debug!(file = %f.path, "skipping unchanged file (incremental)"); + false + } + _ => true, + } + }); + let skipped = before_count - files.len(); + if skipped > 0 { + println!( + " {} skipped (unchanged since last scan)", + skipped.to_string().dimmed() + ); + } + } + if files.is_empty() { println!("{}", "No files to scan.".yellow()); return Ok(0); } - println!( - "{} {} files to reviewโ€ฆ", - "๐Ÿ”".to_string(), - files.len().to_string().cyan(), - ); + println!("๐Ÿ” {} files to reviewโ€ฆ", files.len().to_string().cyan()); // 2. Calculate total lines let total_lines: usize = files.iter().map(|f| f.lines).sum(); @@ -78,11 +114,17 @@ pub async fn execute_scan( String::new() }; - println!(" Reviewing{}โ€ฆ", batch_label); + println!(" Reviewing{batch_label}โ€ฆ"); - let (issues, _summary, tokens) = - crate::engine::llm::scan_files(llm_config, &files_content, &config.focus, &config.rules) - .await?; + let (issues, _summary, tokens) = crate::engine::llm::scan_files( + llm_config, + &files_content, + &effective_focus, + &config.rules, + &config.response_format, + None, + ) + .await?; all_issues.extend(issues); if tokens.is_some() { @@ -93,7 +135,9 @@ pub async fn execute_scan( // 5. Build response and format let issue_count = all_issues.len(); let min_severity = config.hook.min_severity_level(); - let should_block = all_issues.iter().any(|i| i.severity >= min_severity); + // Ord order is Critical(0) < Major(1) < Minor(2) < Info(3), so "at or above + // min_severity" means Ord value <= min_severity. + let should_block = all_issues.iter().any(|i| i.severity <= min_severity); let response = crate::engine::ScanResponse { issues: all_issues, @@ -111,7 +155,22 @@ pub async fn execute_scan( let formatter = formatter_for(format); let output = formatter.format_scan(&response)?; - println!("{}", output); + println!("{output}"); + + // 6. Save scan cache for incremental mode + if opts.incremental { + let root_abs = root.canonicalize().unwrap_or_else(|_| root.clone()); + let mut cache = ScanCache::load().unwrap_or_default(); + for f in &files { + let abs_path = root_abs.join(&f.path); + let Some(hash) = file_content_hash(&abs_path) else { + continue; // can't read file, skip cache entry + }; + cache.set(&root_abs, &f.path, &hash); + } + cache.save()?; + debug!(cached = files.len(), "saved scan cache"); + } if response.should_block && config.hook.mode == "block" { Ok(2) @@ -119,3 +178,61 @@ pub async fn execute_scan( Ok(0) } } + +/// Compute a short SHA256 hash of a file's content for incremental scanning. +/// Returns None if the file cannot be read (caller should rescan it). +#[allow(clippy::format_collect)] +fn file_content_hash(path: &std::path::Path) -> Option { + use sha2::Digest; + let bytes = std::fs::read(path).ok()?; + let hash = sha2::Sha256::digest(&bytes); + // Use first 8 bytes as hex โ€” consistent representation, no truncation + Some(hash.iter().take(8).map(|b| format!("{b:02x}")).collect()) +} + +/// Cache of file content hashes for incremental scanning. +/// Stored as JSON in ~/.cora/scan-cache.json. +#[derive(Debug, Default, serde::Serialize, serde::Deserialize)] +struct ScanCache { + /// Key: canonical root path, Value: { `file_path`: hash } + projects: std::collections::HashMap>, +} + +impl ScanCache { + fn cache_path() -> anyhow::Result { + let home = dirs::home_dir().context("cannot determine home directory")?; + Ok(home.join(".cora").join("scan-cache.json")) + } + + fn load() -> Result { + let path = Self::cache_path()?; + if !path.is_file() { + return Ok(Self::default()); + } + let content = std::fs::read_to_string(&path)?; + serde_json::from_str(&content).context("failed to parse scan cache") + } + + fn save(&self) -> Result<()> { + let path = Self::cache_path()?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let content = serde_json::to_string_pretty(self)?; + std::fs::write(&path, content)?; + Ok(()) + } + + fn get(&self, root: &std::path::Path, file: &str) -> Option { + let root_key = root.to_string_lossy().to_string(); + self.projects.get(&root_key)?.get(file).cloned() + } + + fn set(&mut self, root: &std::path::Path, file: &str, hash: &str) { + let root_key = root.to_string_lossy().to_string(); + self.projects + .entry(root_key) + .or_default() + .insert(file.to_string(), hash.to_string()); + } +} diff --git a/src/commands/upload.rs b/src/commands/upload.rs new file mode 100644 index 00000000..7b65279c --- /dev/null +++ b/src/commands/upload.rs @@ -0,0 +1,215 @@ +use std::io::Read; + +use anyhow::{Context, Result, bail}; +use colored::Colorize; +use serde_json::json; + +/// Upload options for the upload-sarif subcommand. +pub struct UploadOptions { + /// Path to SARIF file to upload (None = read from stdin). + pub file: Option, + /// GitHub repository in "owner/repo" format. + pub repo: Option, + /// Git ref name for the upload. + pub ref_name: Option, + /// GitHub token for authentication. + pub token: Option, +} + +/// Upload a SARIF file to GitHub Code Scanning. +/// +/// Reads the SARIF content from the specified file or stdin, then POSTs it +/// to the GitHub Code Scanning API. +#[allow(clippy::items_after_statements, clippy::cast_precision_loss)] +pub async fn execute_upload(opts: &UploadOptions) -> Result { + let token = opts + .token + .as_deref() + .context("GITHUB_TOKEN is required (set env var or pass --token)")? + .to_string(); + + let (owner, repo) = resolve_repo(opts.repo.as_deref())?; + let ref_name = resolve_ref(opts.ref_name.as_deref())?; + + tracing::debug!( + owner = %owner, + repo = %repo, + ref_name = %ref_name, + "uploading SARIF to GitHub Code Scanning" + ); + + // Read SARIF content + let sarif_content = read_sarif(opts.file.as_deref())?; + + // Validate file size (GitHub limits SARIF uploads to 10MB) + const MAX_SARIF_SIZE: usize = 10 * 1024 * 1024; // 10MB + if sarif_content.len() > MAX_SARIF_SIZE { + let size_mb = sarif_content.len() as f64 / (1024.0 * 1024.0); + bail!( + "SARIF file is {} bytes ({:.2}MB), exceeding GitHub's 10MB limit. Consider reviewing a smaller diff.", + sarif_content.len(), + size_mb + ); + } + + // Validate it's valid JSON (basic sanity check) + let parsed: serde_json::Value = + serde_json::from_str(&sarif_content).context("SARIF file does not contain valid JSON")?; + + // Ensure it has the $schema field (basic SARIF validation) + if parsed.get("$schema").is_none() { + bail!("File does not appear to be a valid SARIF document (missing $schema)"); + } + + println!( + "{} Uploading SARIF to {}/{} (ref: {})", + "โ†’".cyan(), + owner, + repo, + ref_name + ); + + // Build the upload request + let url = format!("https://api.github.com/repos/{owner}/{repo}/code-scanning/sarifs"); + + let client = reqwest::Client::new(); + let response = client + .post(&url) + .header("Accept", "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28") + .header("Authorization", format!("Bearer {token}")) + .json(&json!({ "commit_sha": ref_name, "sarif": &sarif_content })) + .send() + .await + .context("Failed to send request to GitHub API")?; + + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + + if status.is_success() { + println!( + "{} SARIF uploaded successfully to GitHub Code Scanning.", + "โœ“".green() + ); + + // Parse the response for additional info + if let Ok(resp_json) = serde_json::from_str::(&body) { + if let Some(id) = resp_json.get("id").and_then(|v| v.as_str()) { + println!(" Analysis ID: {id}"); + } + } + + Ok(0) + } else { + // Try to extract a useful error message + let error_msg = serde_json::from_str::(&body) + .ok() + .and_then(|v| { + v.get("message") + .or_else(|| v.get("errors")?.get(0)?.get("message")) + .and_then(|m| m.as_str()) + .map(std::string::ToString::to_string) + }) + .unwrap_or_else(|| body.clone()); + + eprintln!( + "{} Upload failed (HTTP {}): {}", + "โœ—".red(), + status, + error_msg + ); + + // Common error hints + let hint = match status.as_u16() { + 401 => Some("Check that your GITHUB_TOKEN is valid and not expired."), + 403 => Some( + "The token may lack security_events write permission. Use a token with the 'security_events' scope.", + ), + 404 => Some("Check the repository name and that you have access to it."), + 422 => Some("The SARIF file may be invalid or exceed size limits (max 10MB)."), + 429 => Some("Rate limited. Wait a moment and try again."), + _ => None, + }; + + if let Some(h) = hint { + eprintln!(" {}", h.yellow()); + } + + Ok(1) + } +} + +/// Read SARIF content from a file or stdin. +fn read_sarif(file: Option<&str>) -> Result { + if let Some(path) = file { + std::fs::read_to_string(path).with_context(|| format!("Failed to read SARIF file: {path}")) + } else { + eprintln!( + "{} Reading SARIF from stdin (Ctrl+D to finish)...", + "โ„น".blue() + ); + let mut content = String::new(); + std::io::stdin() + .read_to_string(&mut content) + .context("Failed to read from stdin")?; + Ok(content) + } +} + +/// Resolve the GitHub repository from CLI flag, env var, or git remote. +fn resolve_repo(cli_repo: Option<&str>) -> Result<(String, String)> { + if let Some(repo) = cli_repo { + let parts: Vec<&str> = repo.split('/').collect(); + if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() { + bail!("Invalid repository format '{repo}'. Expected 'owner/repo'."); + } + return Ok((parts[0].to_string(), parts[1].to_string())); + } + + // Try to detect from git remote + match crate::git::get_repo_info() { + Ok((owner, repo_name, _branch)) => { + if owner == "unknown" || repo_name == "unknown" { + bail!( + "Could not detect repository from git remote. \ + Set GITHUB_REPOSITORY or pass --repo owner/repo." + ); + } + Ok((owner, repo_name)) + } + Err(_) => { + bail!( + "Could not detect repository. Set GITHUB_REPOSITORY env var or pass --repo owner/repo." + ); + } + } +} + +/// Resolve the git ref name from CLI flag, env var, or current HEAD. +fn resolve_ref(cli_ref: Option<&str>) -> Result { + if let Some(r) = cli_ref { + return Ok(r.to_string()); + } + + // Try to get from git + if let Ok(branch) = crate::git::get_current_branch() { + Ok(branch) + } else { + // Try to get the current HEAD commit SHA as fallback + let output = std::process::Command::new("git") + .args(["rev-parse", "HEAD"]) + .output(); + + match output { + Ok(out) if out.status.success() => { + let sha = String::from_utf8_lossy(&out.stdout).trim().to_string(); + Ok(sha) + } + _ => { + bail!( + "Could not detect current git ref. Pass --ref-name or run inside a git repo." + ); + } + } + } +} diff --git a/src/config/loader.rs b/src/config/loader.rs index 40e14ecc..ee5a2042 100644 --- a/src/config/loader.rs +++ b/src/config/loader.rs @@ -1,20 +1,30 @@ use std::path::{Path, PathBuf}; -use anyhow::{Context, Result}; +use crate::error::CoraError; use tracing::debug; +use crate::config::providers::{PRESETS, detected_presets}; use crate::config::schema::{Config, CoraFile}; use crate::engine::LLMConfig; -/// The name of the config file we search for. +/// The name of the config file we search for in projects. const CONFIG_FILENAME: &str = ".cora.yaml"; +/// Name of the global config file. +const GLOBAL_CONFIG_FILENAME: &str = "config.yaml"; + /// Name of the secret config file for API keys (never committed). -const AUTH_FILENAME: &str = "config.toml"; +const AUTH_FILENAME: &str = "auth.toml"; + +/// Name of the old auth file (for migration). +const OLD_AUTH_FILENAME: &str = "config.toml"; + +/// Marker file created after successful migration. +const MIGRATION_MARKER: &str = ".migrated"; /// Locate the `.cora.yaml` config by walking parent directories from `start`. /// Returns the path and parsed content, or `None` if not found. -pub fn find_cora_file(start: &Path) -> Result> { +pub fn find_cora_file(start: &Path) -> std::result::Result, CoraError> { let mut dir = if start.is_file() { start.parent().unwrap_or(start).to_path_buf() } else { @@ -26,8 +36,14 @@ pub fn find_cora_file(start: &Path) -> Result> { if candidate.is_file() { debug!(path = %candidate.display(), "found .cora.yaml"); let content = std::fs::read_to_string(&candidate) - .with_context(|| format!("failed to read {}", candidate.display()))?; - let cora = CoraFile::from_str(&content)?; + .map_err(|e| CoraError::ConfigRead(format!("{}: {}", candidate.display(), e)))?; + let cora = CoraFile::from_str(&content).map_err(|e| { + CoraError::ConfigParse(format!( + "{}\n โ†’ file: {}\n โ†’ hint: check for syntax errors (indentation, colons, trailing spaces)", + e, + candidate.display() + )) + })?; return Ok(Some((candidate, cora))); } @@ -38,7 +54,173 @@ pub fn find_cora_file(start: &Path) -> Result> { } } -/// Load the full resolved config: defaults โ† .cora.yaml โ† CLI overrides. +/// Load the global config from `~/.cora/config.yaml`. +/// Returns `None` if the file doesn't exist or can't be parsed. +fn load_global_config() -> std::result::Result, CoraError> { + let dir = cora_dir()?; + let path = dir.join(GLOBAL_CONFIG_FILENAME); + if !path.is_file() { + return Ok(None); + } + let content = std::fs::read_to_string(&path) + .map_err(|e| CoraError::ConfigRead(format!("{}: {}", path.display(), e)))?; + let cora = CoraFile::from_str(&content)?; + debug!(path = %path.display(), "loaded global config"); + Ok(Some(cora)) +} + +/// Migrate old `~/.cora/config.toml` to the new format if it exists. +/// - Non-secret keys โ†’ `~/.cora/config.yaml` +/// - `api_key` โ†’ `~/.cora/auth.toml` +/// - Delete the old file after successful migration. +/// - Creates `.migrated` marker to prevent re-running. +#[allow( + clippy::too_many_lines, + clippy::cast_possible_truncation, + clippy::cast_sign_loss +)] +fn migrate_old_config() { + let Ok(dir) = cora_dir() else { + return; + }; + let old_path = dir.join(OLD_AUTH_FILENAME); + if !old_path.is_file() { + return; + } + + // Check if migration already completed + if dir.join(MIGRATION_MARKER).is_file() { + return; + } + + let content = match std::fs::read_to_string(&old_path) { + Ok(c) => c, + Err(e) => { + debug!("failed to read old config for migration: {}", e); + return; + } + }; + + let table: toml::Table = match content.parse::() { + Ok(t) => t, + Err(e) => { + debug!( + "old config.toml is not valid TOML, skipping migration: {}", + e + ); + return; + } + }; + + // Check if there's an api_key + let api_key = table + .get("auth") + .and_then(|a| a.get("api_key")) + .and_then(|k| k.as_str()) + .map(std::string::ToString::to_string) + .or_else(|| { + table + .get("api_key") + .and_then(|k| k.as_str()) + .map(std::string::ToString::to_string) + }); + + // Extract non-secret config fields into a CoraFile + let mut cora = CoraFile::default(); + + if let Some(provider) = table.get("provider").and_then(|v| v.as_table()) { + let mut ps = crate::config::schema::ProviderSection::default(); + if let Some(v) = provider.get("provider").and_then(|v| v.as_str()) { + ps.provider = Some(v.to_string()); + } + if let Some(v) = provider.get("model").and_then(|v| v.as_str()) { + ps.model = Some(v.to_string()); + } + if let Some(v) = provider.get("base_url").and_then(|v| v.as_str()) { + ps.base_url = Some(v.to_string()); + } + // Only set if we found something + if ps.provider.is_some() || ps.model.is_some() || ps.base_url.is_some() { + cora.provider = Some(ps); + } + } + + if let Some(output) = table.get("output").and_then(|v| v.as_table()) { + let mut os = crate::config::schema::OutputSection::default(); + if let Some(v) = output.get("format").and_then(|v| v.as_str()) { + os.format = Some(v.to_string()); + } + if let Some(v) = output.get("color").and_then(toml::Value::as_bool) { + os.color = Some(v); + } + if os.format.is_some() || os.color.is_some() { + cora.output = Some(os); + } + } + + if let Some(hook) = table.get("hook").and_then(|v| v.as_table()) { + let mut hs = crate::config::schema::HookSection::default(); + if let Some(v) = hook.get("mode").and_then(|v| v.as_str()) { + hs.mode = Some(v.to_string()); + } + if let Some(v) = hook.get("min_severity").and_then(|v| v.as_str()) { + hs.min_severity = Some(v.to_string()); + } + if let Some(v) = hook.get("max_diff_size").and_then(toml::Value::as_integer) { + hs.max_diff_size = Some(v as usize); + } + if hs.mode.is_some() || hs.min_severity.is_some() || hs.max_diff_size.is_some() { + cora.hook = Some(hs); + } + } + + // Write api_key to auth.toml if present (use TOML library to avoid injection) + if let Some(key) = api_key { + let auth_path = dir.join(AUTH_FILENAME); + let mut table = toml::Table::new(); + table.insert("api_key".to_string(), toml::Value::String(key)); + let content = table.to_string(); + if let Err(e) = std::fs::write(&auth_path, content) { + debug!("failed to write migrated auth.toml: {}", e); + return; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let perms = std::fs::Permissions::from_mode(0o600); + let _ = std::fs::set_permissions(&auth_path, perms); + } + } + + // Write config to config.yaml if there are non-secret fields + let has_config = cora.provider.is_some() + || cora.output.is_some() + || cora.hook.is_some() + || cora.focus.is_some() + || cora.rules.is_some() + || cora.ignore.is_some(); + + if has_config { + let config_path = dir.join(GLOBAL_CONFIG_FILENAME); + if let Ok(yaml) = serde_yaml_ng::to_string(&cora) { + if let Err(e) = std::fs::write(&config_path, yaml) { + debug!("failed to write migrated config.yaml: {}", e); + return; + } + } + } + + // Delete old file and create migration marker + if let Err(e) = std::fs::remove_file(&old_path) { + debug!("failed to remove old config.toml after migration: {}", e); + } else { + // Create marker to prevent re-running migration + let _ = std::fs::write(dir.join(MIGRATION_MARKER), ""); + debug!("migrated ~/.cora/config.toml to new format"); + } +} + +/// Load the full resolved config: defaults โ† global config โ† .cora.yaml โ† CLI overrides. /// /// `cli_provider`, `cli_model`, `cli_api_key`, and `cli_format` are `None` /// when the user did not pass the corresponding flag. @@ -47,18 +229,31 @@ pub fn load_config( cli_provider: Option<&str>, cli_model: Option<&str>, cli_base_url: Option<&str>, - _cli_api_key: Option<&str>, cli_format: Option<&str>, no_color: bool, -) -> Result { +) -> std::result::Result { let mut config = Config::default(); - // 1. Load .cora.yaml + // Run migration silently on first access + migrate_old_config(); + + // 1. Load global config (~/.cora/config.yaml) + if let Some(cora) = load_global_config()? { + cora.merge_into(&mut config); + } + + // 2. Load project config (.cora.yaml) if let Some(path) = cli_config_path { let path = Path::new(path); let content = std::fs::read_to_string(path) - .with_context(|| format!("failed to read config at {}", path.display()))?; - let cora = CoraFile::from_str(&content)?; + .map_err(|e| CoraError::ConfigRead(format!("{}: {}", path.display(), e)))?; + let cora = CoraFile::from_str(&content).map_err(|e| { + CoraError::ConfigParse(format!( + "{}\n โ†’ file: {}\n โ†’ hint: check for syntax errors (indentation, colons, trailing spaces)", + e, + path.display() + )) + })?; cora.merge_into(&mut config); debug!(path = %path.display(), "loaded explicit config"); } else if let Some((path, cora)) = find_cora_file(&std::env::current_dir()?)? { @@ -68,7 +263,7 @@ pub fn load_config( debug!("no .cora.yaml found, using defaults"); } - // 2. CLI overrides + // 3. CLI overrides if let Some(p) = cli_provider { config.provider.provider = p.to_string(); } @@ -89,74 +284,148 @@ pub fn load_config( } /// Build an `LLMConfig` from the resolved `Config`, fetching the API key -/// from: CLI flag โ†’ env CORA_API_KEY โ†’ ~/.config/cora/config.toml. +/// from: CLI flag โ†’ env `CORA_API_KEY` โ†’ ~/.cora/auth.toml. +/// +/// If none of those are set, auto-detect from known provider env vars (`OPENAI_API_KEY`, etc.) +/// and configure `provider/model/base_url` from the matching preset. pub fn build_llm_config( config: &Config, cli_api_key: Option<&str>, -) -> Result { - let api_key = if let Some(key) = cli_api_key { - key.to_string() - } else if let Some(key) = std::env::var("CORA_API_KEY").ok() { - key +) -> std::result::Result { + // Resolve the API key and optional auto-detected preset in one pass. + let (api_key, auto_preset) = if let Some(key) = cli_api_key { + (key.to_string(), None) + } else if let Ok(key) = std::env::var("CORA_API_KEY") { + (key, None) } else if let Some(key) = load_api_key_from_auth_file()? { - key + (key, None) } else { - anyhow::bail!( - "no API key found. Set CORA_API_KEY env var, pass --api-key, or run `cora auth login`" - ); + // No CORA_API_KEY or stored key โ€” auto-detect from provider presets + let detected = detected_presets(); + if detected.is_empty() { + let _available: Vec = PRESETS + .iter() + .map(|p| format!(" {} (set {})", p.name, p.env_key)) + .collect(); + return Err(CoraError::NoApiKey); + } + + // Use the first detected provider + let preset = detected[0]; + let key = std::env::var(preset.env_key).unwrap_or_default(); + + if detected.len() > 1 { + let names: Vec<&str> = detected.iter().map(|p| p.name).collect(); + eprintln!( + "โ„น๏ธ Multiple providers detected ({}). Using first: {}. Set CORA_PROVIDER or use --provider to override.", + names.join(", "), + preset.name + ); + } else { + debug!(provider = preset.name, "auto-detected provider from env"); + } + + (key, Some(preset)) }; + // Resolve provider/model/base_url: CORA_* env > auto-detected preset > config defaults + let cora_provider = std::env::var("CORA_PROVIDER").ok(); + let cora_model = std::env::var("CORA_MODEL").ok(); + let cora_base_url = std::env::var("CORA_BASE_URL").ok(); + + let provider = cora_provider + .or_else(|| auto_preset.map(|p| p.name.to_string())) + .unwrap_or_else(|| config.provider.provider.clone()); + + let model = cora_model + .or_else(|| auto_preset.map(|p| p.default_model.to_string())) + .unwrap_or_else(|| config.provider.model.clone()); + + let base_url = cora_base_url + .or_else(|| { + // Check if the auto-detected preset has a custom URL override + auto_preset.and_then(|p| std::env::var(p.env_url).ok()) + }) + .or_else(|| auto_preset.map(|p| p.default_base_url.to_string())) + .unwrap_or_else(|| config.provider.base_url.clone()); + Ok(LLMConfig { api_key, - base_url: config.provider.base_url.clone(), - model: config.provider.model.clone(), - provider: config.provider.provider.clone(), + base_url, + model, + provider, + temperature: config.temperature, + max_tokens: config.max_tokens, + timeout: config.timeout, }) } -/// Read the stored API key from ~/.config/cora/config.toml. -pub fn load_api_key_from_auth_file() -> Result> { - let dir = dirs::config_dir() - .context("cannot determine config directory")? - .join("cora"); +/// Get the cora config directory: ~/.cora/ +pub fn cora_dir() -> std::result::Result { + let home = dirs::home_dir() + .ok_or_else(|| CoraError::ConfigRead("cannot determine home directory".into()))?; + Ok(home.join(".cora")) +} +/// Read the stored API key from ~/.cora/auth.toml. +pub fn load_api_key_from_auth_file() -> std::result::Result, CoraError> { + let dir = cora_dir()?; let path = dir.join(AUTH_FILENAME); if !path.is_file() { return Ok(None); } + // Security: warn if auth file has overly permissive permissions (Unix only) + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Ok(meta) = std::fs::metadata(&path) { + let mode = meta.permissions().mode(); + if mode & 0o077 != 0 { + tracing::warn!( + "auth file has overly permissive permissions ({:o}). Run: chmod 600 {}", + mode & 0o777, + path.display() + ); + } + } + } + let content = std::fs::read_to_string(&path) - .with_context(|| format!("failed to read {}", path.display()))?; + .map_err(|e| CoraError::ConfigRead(format!("{}: {}", path.display(), e)))?; // Simple TOML: expect `[auth]\napi_key = "..."` or just `api_key = "..."` let value: toml::Table = content .parse::() - .context("auth config file is not valid TOML")?; + .map_err(|e| CoraError::AuthError(format!("invalid TOML: {}", e)))?; let key = value .get("auth") .and_then(|a| a.get("api_key")) .and_then(|k| k.as_str()) - .map(|s| s.to_string()) - .or_else(|| value.get("api_key").and_then(|k| k.as_str()).map(|s| s.to_string())); + .map(std::string::ToString::to_string) + .or_else(|| { + value + .get("api_key") + .and_then(|k| k.as_str()) + .map(std::string::ToString::to_string) + }); Ok(key) } -/// Save an API key to ~/.config/cora/config.toml. -pub fn save_api_key(key: &str) -> Result<()> { - let dir = dirs::config_dir() - .context("cannot determine config directory")? - .join("cora"); - - std::fs::create_dir_all(&dir) - .with_context(|| format!("failed to create {}", dir.display()))?; +/// Save an API key to ~/.cora/auth.toml. +pub fn save_api_key(key: &str) -> std::result::Result<(), CoraError> { + let dir = cora_dir()?; + std::fs::create_dir_all(&dir).map_err(|e| CoraError::AuthError(e.to_string()))?; let path = dir.join(AUTH_FILENAME); - let content = format!("api_key = \"{key}\"\n"); + let mut table = toml::Table::new(); + table.insert("api_key".to_string(), toml::Value::String(key.to_string())); + let content = table.to_string(); std::fs::write(&path, content) - .with_context(|| format!("failed to write {}", path.display()))?; + .map_err(|e| CoraError::AuthError(format!("{}: {}", path.display(), e)))?; debug!(path = %path.display(), "saved API key"); @@ -171,32 +440,27 @@ pub fn save_api_key(key: &str) -> Result<()> { Ok(()) } -/// Remove the stored API key from ~/.config/cora/config.toml. -pub fn remove_api_key() -> Result<()> { - let dir = dirs::config_dir() - .context("cannot determine config directory")? - .join("cora"); - +/// Remove the stored API key from ~/.cora/auth.toml. +pub fn remove_api_key() -> std::result::Result<(), CoraError> { + let dir = cora_dir()?; let path = dir.join(AUTH_FILENAME); if path.is_file() { std::fs::remove_file(&path) - .with_context(|| format!("failed to remove {}", path.display()))?; + .map_err(|e| CoraError::AuthError(format!("{}: {}", path.display(), e)))?; debug!("removed API key file"); } Ok(()) } /// Check the auth status: whether an API key is available. -pub fn auth_status() -> Result { +pub fn auth_status() -> std::result::Result { if std::env::var("CORA_API_KEY").is_ok() { Ok(AuthStatus { source: "env var CORA_API_KEY".to_string(), has_key: true, }) } else if load_api_key_from_auth_file()?.is_some() { - let dir = dirs::config_dir() - .context("cannot determine config directory")? - .join("cora"); + let dir = cora_dir()?; Ok(AuthStatus { source: format!("{}", dir.join(AUTH_FILENAME).display()), has_key: true, diff --git a/src/config/mod.rs b/src/config/mod.rs index 703ed0e2..860a557d 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,3 +1,3 @@ pub mod loader; +pub mod providers; pub mod schema; - diff --git a/src/config/providers.rs b/src/config/providers.rs new file mode 100644 index 00000000..b360a3cf --- /dev/null +++ b/src/config/providers.rs @@ -0,0 +1,64 @@ +/// Known provider presets with their expected env vars and defaults. +pub struct ProviderPreset { + pub name: &'static str, + pub env_key: &'static str, // env var for API key + pub env_url: &'static str, // env var for custom base URL + pub default_model: &'static str, + pub default_base_url: &'static str, +} + +pub const PRESETS: &[ProviderPreset] = &[ + ProviderPreset { + name: "openai", + env_key: "OPENAI_API_KEY", + env_url: "OPENAI_BASE_URL", + default_model: "gpt-4o-mini", + default_base_url: "https://api.openai.com/v1", + }, + ProviderPreset { + name: "anthropic", + env_key: "ANTHROPIC_API_KEY", + env_url: "ANTHROPIC_BASE_URL", + default_model: "claude-3-haiku-20240307", + default_base_url: "https://api.anthropic.com/v1", + }, + ProviderPreset { + name: "groq", + env_key: "GROQ_API_KEY", + env_url: "GROQ_BASE_URL", + default_model: "llama-3.1-8b-instant", + default_base_url: "https://api.groq.com/openai/v1", + }, + ProviderPreset { + name: "ollama", + env_key: "OLLAMA_API_KEY", + env_url: "OLLAMA_HOST", + default_model: "llama3.1", + default_base_url: "http://localhost:11434/v1", + }, + ProviderPreset { + name: "zai", + env_key: "ZAI_API_KEY", + env_url: "ZAI_BASE_URL", + default_model: "glm-5.1", + default_base_url: "https://api.z.ai/api/coding/paas/v4", + }, +]; + +/// Check if a provider preset has its API key available in the environment. +pub fn preset_has_key(preset: &ProviderPreset) -> bool { + // Ollama doesn't require an API key โ€” it's always "available" if the host is reachable, + // but for detection purposes we treat it as available if OLLAMA_HOST is set OR if no + // key env var is needed (localhost access). + if preset.name == "ollama" { + return std::env::var("OLLAMA_HOST").is_ok() + || std::env::var("OLLAMA_API_KEY").is_ok() + || std::env::var("CORA_PROVIDER").is_ok_and(|v| v == "ollama"); + } + std::env::var(preset.env_key).is_ok() +} + +/// Return all presets that have their API key detected in the environment. +pub fn detected_presets() -> Vec<&'static ProviderPreset> { + PRESETS.iter().filter(|p| preset_has_key(p)).collect() +} diff --git a/src/config/schema.rs b/src/config/schema.rs index a1cac37a..06db2395 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -1,7 +1,8 @@ -use anyhow::{Context, Result}; -use serde::{Deserialize, Serialize}; - use crate::engine::Severity; +use crate::engine::bundling::types::BundlingConfig; +use crate::engine::rules::types::RulesConfig; +use crate::error::CoraError; +use serde::{Deserialize, Serialize}; /// Runtime configuration โ€” merged from defaults + .cora.yaml + CLI flags. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -18,6 +19,32 @@ pub struct Config { pub hook: HookConfig, /// Output configuration. pub output: OutputConfig, + /// Response format for LLM API calls ("none" or "`json_object`"). + pub response_format: String, + /// Optional custom system prompt that replaces the default for review. + pub review_system_prompt_override: Option, + /// Optional custom system prompt file path for review. + pub review_system_prompt_file: Option, + /// Optional custom system prompt that replaces the default for scan. + pub scan_system_prompt_override: Option, + /// Optional custom system prompt file path for scan. + pub scan_system_prompt_file: Option, + /// LLM temperature for deterministic output. + pub temperature: f32, + /// Max tokens for LLM responses. + pub max_tokens: u32, + /// HTTP timeout in seconds for LLM requests. + pub timeout: u64, + /// Cache TTL in minutes for review caching. + pub cache_ttl: u64, + /// Static analysis context injection for reviews. + pub static_analysis: StaticAnalysisConfig, + /// Rule engine configuration. + pub rules_config: RulesConfig, + /// Context chain configuration โ€” cross-file dependency extraction. + pub context_chain: crate::engine::context::types::ContextConfig, + /// File bundling configuration for scan/review grouping. + pub bundling: BundlingConfig, } /// Provider configuration. @@ -41,6 +68,7 @@ pub struct HookConfig { pub mode: String, pub min_severity: String, pub max_diff_size: usize, + pub on_violation: String, // "warn" | "disallow" } /// Output configuration. @@ -58,27 +86,51 @@ impl Default for Config { model: "gpt-4o-mini".to_string(), base_url: "https://api.openai.com/v1".to_string(), }, - focus: vec!["security".into(), "performance".into(), "bugs".into(), "best_practice".into()], + focus: vec![ + "security".into(), + "performance".into(), + "bugs".into(), + "best_practice".into(), + ], rules: vec![], ignore: IgnoreConfig { - files: vec!["node_modules/**".into(), "dist/**".into(), "target/**".into(), ".git/**".into()], + files: vec![ + "node_modules/**".into(), + "dist/**".into(), + "target/**".into(), + ".git/**".into(), + ], rules: vec![], }, hook: HookConfig { mode: "warn".to_string(), min_severity: "major".to_string(), max_diff_size: 50 * 1024, + on_violation: "warn".to_string(), }, output: OutputConfig { format: "pretty".to_string(), color: true, }, + response_format: "none".to_string(), + review_system_prompt_override: None, + review_system_prompt_file: None, + scan_system_prompt_override: None, + scan_system_prompt_file: None, + temperature: 0.0, + max_tokens: 4096, + timeout: 120, + cache_ttl: 1440, // 24h in minutes + static_analysis: StaticAnalysisConfig::default(), + rules_config: RulesConfig::default(), + context_chain: crate::engine::context::types::ContextConfig::default(), + bundling: BundlingConfig::default(), } } } impl HookConfig { - /// Parse the min_severity string into a Severity enum. + /// Parse the `min_severity` string into a Severity enum. pub fn min_severity_level(&self) -> Severity { Severity::from_str_lossy(&self.min_severity) } @@ -87,66 +139,1012 @@ impl HookConfig { /// Serde-compatible schema for the `.cora.yaml` configuration file. #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct CoraFile { + #[serde(skip_serializing_if = "Option::is_none")] pub provider: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub focus: Option>, + #[serde(skip_serializing_if = "Option::is_none")] pub rules: Option>, + #[serde(skip_serializing_if = "Option::is_none")] pub ignore: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub hook: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub output: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub review: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub scan: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub llm: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub rules_engine: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub bundling: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ProviderSection { + #[serde(skip_serializing_if = "Option::is_none")] pub provider: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub model: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub base_url: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct IgnoreSection { + #[serde(skip_serializing_if = "Option::is_none")] pub files: Option>, + #[serde(skip_serializing_if = "Option::is_none")] pub rules: Option>, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct HookSection { + #[serde(skip_serializing_if = "Option::is_none")] pub mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub min_severity: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub max_diff_size: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub on_violation: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct OutputSection { + #[serde(skip_serializing_if = "Option::is_none")] pub format: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub color: Option, } +/// Review-specific configuration section. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ReviewSection { + #[serde(skip_serializing_if = "Option::is_none")] + pub response_format: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub system_prompt: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub system_prompt_file: Option, + /// Static analysis context injection (e.g., clippy output). + #[serde(skip_serializing_if = "Option::is_none")] + pub static_analysis: Option, + /// Context chain configuration (cross-file dependency extraction). + #[serde(skip_serializing_if = "Option::is_none")] + pub context_chain: Option, +} + +/// Static analysis configuration โ€” inject linter/compiler output as review context. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct StaticAnalysisConfig { + /// Automatically run `cargo clippy` and inject output as context. + /// Adds ~2-5 seconds to review time. + #[serde(default, skip_serializing_if = "is_default")] + pub auto_clippy: bool, + /// Path to a file containing pre-computed static analysis output (e.g., clippy JSON). + /// If set, this file's content is injected instead of running auto_clippy. + #[serde(skip_serializing_if = "Option::is_none")] + pub clippy_output_file: Option, +} + +fn is_default(val: &T) -> bool { + *val == T::default() +} + +/// Scan-specific configuration section. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ScanSection { + #[serde(skip_serializing_if = "Option::is_none")] + pub system_prompt: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub system_prompt_file: Option, +} + +/// LLM-specific configuration section (temperature, `max_tokens`, timeout, `cache_ttl`). +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct LlmSection { + #[serde(skip_serializing_if = "Option::is_none")] + pub temperature: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub timeout: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cache_ttl: Option, +} + +fn default_max_findings() -> usize { + 5 +} + +/// Rule engine configuration section for `.cora.yaml`. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct RulesSection { + #[serde(default, skip_serializing_if = "is_default")] + pub enabled: bool, + #[serde(default = "default_max_findings")] + pub max_findings: usize, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub custom: Vec, +} + +/// File bundling configuration section for `.cora.yaml`. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct BundlingSection { + #[serde(skip_serializing_if = "Option::is_none")] + pub max_chars_per_group: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_files_per_group: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub strategy: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub coalesce_by_directory: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub coalesce_by_language: Option, +} + impl CoraFile { - pub fn from_str(content: &str) -> Result { - serde_yaml::from_str(content).context("failed to parse .cora.yaml") + pub fn from_str(content: &str) -> std::result::Result { + serde_yaml_ng::from_str(content).map_err(|e| CoraError::ConfigParse(e.to_string())) } /// Merge this file config into a `Config`, overwriting only fields that are present. + #[allow(clippy::assigning_clones)] pub fn merge_into(&self, config: &mut Config) { if let Some(p) = &self.provider { - if let Some(v) = &p.provider { config.provider.provider = v.clone(); } - if let Some(v) = &p.model { config.provider.model = v.clone(); } - if let Some(v) = &p.base_url { config.provider.base_url = v.clone(); } + if let Some(v) = &p.provider { + config.provider.provider.clone_from(v); + } + if let Some(v) = &p.model { + config.provider.model.clone_from(v); + } + if let Some(v) = &p.base_url { + config.provider.base_url.clone_from(v); + } + } + if let Some(v) = &self.focus { + config.focus.clone_from(v); + } + if let Some(v) = &self.rules { + config.rules.clone_from(v); } - if let Some(v) = &self.focus { config.focus = v.clone(); } - if let Some(v) = &self.rules { config.rules = v.clone(); } if let Some(ig) = &self.ignore { - if let Some(v) = &ig.files { config.ignore.files = v.clone(); } - if let Some(v) = &ig.rules { config.ignore.rules = v.clone(); } + if let Some(v) = &ig.files { + config.ignore.files.clone_from(v); + } + if let Some(v) = &ig.rules { + config.ignore.rules.clone_from(v); + } } if let Some(h) = &self.hook { - if let Some(v) = &h.mode { config.hook.mode = v.clone(); } - if let Some(v) = &h.min_severity { config.hook.min_severity = v.clone(); } - if let Some(v) = h.max_diff_size { config.hook.max_diff_size = v; } + if let Some(v) = &h.mode { + config.hook.mode.clone_from(v); + } + if let Some(v) = &h.min_severity { + config.hook.min_severity.clone_from(v); + } + if let Some(v) = h.max_diff_size { + config.hook.max_diff_size = v; + } + if let Some(v) = &h.on_violation { + config.hook.on_violation.clone_from(v); + } } if let Some(o) = &self.output { - if let Some(v) = &o.format { config.output.format = v.clone(); } - if let Some(v) = o.color { config.output.color = v; } + if let Some(v) = &o.format { + config.output.format.clone_from(v); + } + if let Some(v) = o.color { + config.output.color = v; + } + } + if let Some(r) = &self.review { + if let Some(v) = &r.response_format { + config.response_format.clone_from(v); + } + if let Some(v) = &r.system_prompt { + config.review_system_prompt_override = Some(v.clone()); + } + if let Some(v) = &r.system_prompt_file { + config.review_system_prompt_file = Some(v.clone()); + } + if let Some(sa) = &r.static_analysis { + config.static_analysis.clone_from(sa); + } } + if let Some(s) = &self.scan { + if let Some(v) = &s.system_prompt { + config.scan_system_prompt_override = Some(v.clone()); + } + if let Some(v) = &s.system_prompt_file { + config.scan_system_prompt_file = Some(v.clone()); + } + } + if let Some(llm) = &self.llm { + if let Some(v) = llm.temperature { + config.temperature = v; + } + if let Some(v) = llm.max_tokens { + config.max_tokens = v; + } + if let Some(v) = llm.timeout { + config.timeout = v; + } + if let Some(v) = llm.cache_ttl { + config.cache_ttl = v; + } + } + if let Some(re) = &self.rules_engine { + config.rules_config.enabled = re.enabled; + config.rules_config.max_findings = re.max_findings; + if !re.custom.is_empty() { + config.rules_config.custom_rules = re.custom.clone(); + } + } + if let Some(b) = &self.bundling { + if let Some(v) = b.max_chars_per_group { + config.bundling.max_chars_per_group = v; + } + if let Some(v) = b.max_files_per_group { + config.bundling.max_files_per_group = v; + } + if let Some(v) = b.strategy { + config.bundling.strategy = v; + } + if let Some(v) = b.coalesce_by_directory { + config.bundling.coalesce_by_directory = v; + } + if let Some(v) = b.coalesce_by_language { + config.bundling.coalesce_by_language = v; + } + } + } +} + +#[cfg(test)] +#[allow(clippy::float_cmp)] +mod tests { + use super::*; + + // โ”€โ”€โ”€ Config::default() โ”€โ”€โ”€ + + #[test] + fn config_default_provider() { + let cfg = Config::default(); + assert_eq!(cfg.provider.provider, "openai"); + assert_eq!(cfg.provider.model, "gpt-4o-mini"); + assert_eq!(cfg.provider.base_url, "https://api.openai.com/v1"); + } + + #[test] + fn config_default_focus() { + let cfg = Config::default(); + assert_eq!( + cfg.focus, + vec!["security", "performance", "bugs", "best_practice"] + ); + } + + #[test] + fn config_default_rules_empty() { + let cfg = Config::default(); + assert!(cfg.rules.is_empty()); + } + + #[test] + fn config_default_ignore_files() { + let cfg = Config::default(); + assert!(cfg.ignore.files.contains(&"node_modules/**".to_string())); + assert!(cfg.ignore.files.contains(&"dist/**".to_string())); + assert!(cfg.ignore.files.contains(&"target/**".to_string())); + assert!(cfg.ignore.files.contains(&".git/**".to_string())); + } + + #[test] + fn config_default_hook() { + let cfg = Config::default(); + assert_eq!(cfg.hook.mode, "warn"); + assert_eq!(cfg.hook.min_severity, "major"); + assert_eq!(cfg.hook.max_diff_size, 50 * 1024); + assert_eq!(cfg.hook.on_violation, "warn"); + } + + #[test] + fn config_default_output() { + let cfg = Config::default(); + assert_eq!(cfg.output.format, "pretty"); + assert!(cfg.output.color); + } + + // โ”€โ”€โ”€ CoraFile::merge_into โ”€โ”€โ”€ + + #[test] + fn merge_empty_cora_file_leaves_defaults() { + let mut cfg = Config::default(); + let cora = CoraFile::default(); + cora.merge_into(&mut cfg); + assert_eq!(cfg.provider.provider, "openai"); + assert_eq!(cfg.provider.model, "gpt-4o-mini"); + assert_eq!(cfg.output.format, "pretty"); + } + + #[test] + fn merge_provider_overrides() { + let mut cfg = Config::default(); + let cora = CoraFile { + provider: Some(ProviderSection { + provider: Some("anthropic".to_string()), + model: Some("claude-3-haiku".to_string()), + base_url: Some("https://api.anthropic.com/v1".to_string()), + }), + ..Default::default() + }; + cora.merge_into(&mut cfg); + assert_eq!(cfg.provider.provider, "anthropic"); + assert_eq!(cfg.provider.model, "claude-3-haiku"); + assert_eq!(cfg.provider.base_url, "https://api.anthropic.com/v1"); + } + + #[test] + fn merge_partial_provider() { + let mut cfg = Config::default(); + let cora = CoraFile { + provider: Some(ProviderSection { + provider: Some("ollama".to_string()), + model: None, + base_url: None, + }), + ..Default::default() + }; + cora.merge_into(&mut cfg); + assert_eq!(cfg.provider.provider, "ollama"); + assert_eq!(cfg.provider.model, "gpt-4o-mini"); // unchanged + assert_eq!(cfg.provider.base_url, "https://api.openai.com/v1"); // unchanged + } + + #[test] + fn merge_focus() { + let mut cfg = Config::default(); + let cora = CoraFile { + focus: Some(vec!["security".to_string(), "bugs".to_string()]), + ..Default::default() + }; + cora.merge_into(&mut cfg); + assert_eq!(cfg.focus, vec!["security", "bugs"]); + } + + #[test] + fn merge_rules() { + let mut cfg = Config::default(); + let cora = CoraFile { + rules: Some(vec!["no unwrap".to_string()]), + ..Default::default() + }; + cora.merge_into(&mut cfg); + assert_eq!(cfg.rules, vec!["no unwrap"]); + } + + #[test] + fn merge_ignore() { + let mut cfg = Config::default(); + let cora = CoraFile { + ignore: Some(IgnoreSection { + files: Some(vec!["vendor/**".to_string()]), + rules: Some(vec!["skip-rule-1".to_string()]), + }), + ..Default::default() + }; + cora.merge_into(&mut cfg); + assert_eq!(cfg.ignore.files, vec!["vendor/**"]); + assert_eq!(cfg.ignore.rules, vec!["skip-rule-1"]); + } + + #[test] + fn merge_hook() { + let mut cfg = Config::default(); + let cora = CoraFile { + hook: Some(HookSection { + mode: Some("block".to_string()), + min_severity: Some("critical".to_string()), + max_diff_size: Some(1024), + on_violation: Some("disallow".to_string()), + }), + ..Default::default() + }; + cora.merge_into(&mut cfg); + assert_eq!(cfg.hook.mode, "block"); + assert_eq!(cfg.hook.min_severity, "critical"); + assert_eq!(cfg.hook.max_diff_size, 1024); + assert_eq!(cfg.hook.on_violation, "disallow"); + } + + #[test] + fn merge_output() { + let mut cfg = Config::default(); + let cora = CoraFile { + output: Some(OutputSection { + format: Some("json".to_string()), + color: Some(false), + }), + ..Default::default() + }; + cora.merge_into(&mut cfg); + assert_eq!(cfg.output.format, "json"); + assert!(!cfg.output.color); + } + + // โ”€โ”€โ”€ CoraFile::from_str (YAML parsing) โ”€โ”€โ”€ + + #[test] + fn parse_cora_file_empty() { + let cora = CoraFile::from_str("").unwrap(); + assert!(cora.provider.is_none()); + assert!(cora.focus.is_none()); + } + + #[test] + fn parse_cora_file_full() { + let yaml = r" +provider: + provider: anthropic + model: claude-3-haiku + base_url: https://api.anthropic.com/v1 +focus: + - security + - bugs +rules: + - no unwrap +ignore: + files: + - vendor/** +hook: + mode: block + min_severity: critical +output: + format: json + color: false +"; + let cora = CoraFile::from_str(yaml).unwrap(); + assert_eq!( + cora.provider.as_ref().unwrap().provider.as_deref(), + Some("anthropic") + ); + assert_eq!(cora.focus.as_ref().unwrap().len(), 2); + assert_eq!(cora.rules.as_ref().unwrap().len(), 1); + assert_eq!( + cora.output.as_ref().unwrap().format.as_deref(), + Some("json") + ); + assert_eq!(cora.output.as_ref().unwrap().color, Some(false)); + } + + // โ”€โ”€โ”€ HookConfig::min_severity_level โ”€โ”€โ”€ + + #[test] + fn hook_min_severity_level() { + let cfg = HookConfig { + mode: "warn".to_string(), + min_severity: "critical".to_string(), + max_diff_size: 1024, + on_violation: "warn".to_string(), + }; + assert_eq!(cfg.min_severity_level(), Severity::Critical); + } + + #[test] + fn hook_min_severity_level_unknown() { + let cfg = HookConfig { + mode: "warn".to_string(), + min_severity: "whatever".to_string(), + max_diff_size: 1024, + on_violation: "warn".to_string(), + }; + assert_eq!(cfg.min_severity_level(), Severity::Info); + } + + // โ”€โ”€โ”€ CoraFile serde round-trip โ”€โ”€โ”€ + + #[test] + fn cora_file_yaml_roundtrip() { + let cora = CoraFile { + provider: Some(ProviderSection { + provider: Some("ollama".to_string()), + model: Some("llama3".to_string()), + base_url: Some("http://localhost:11434".to_string()), + }), + focus: Some(vec!["security".to_string()]), + ..Default::default() + }; + let yaml = serde_yaml_ng::to_string(&cora).unwrap(); + let back: CoraFile = serde_yaml_ng::from_str(&yaml).unwrap(); + assert_eq!( + back.provider.as_ref().unwrap().provider.as_deref(), + Some("ollama") + ); + assert_eq!(back.focus.as_ref().unwrap().len(), 1); + } + + // โ”€โ”€โ”€ Config serde round-trip โ”€โ”€โ”€ + + #[test] + fn config_json_roundtrip() { + let cfg = Config::default(); + let json = serde_json::to_string(&cfg).unwrap(); + let back: Config = serde_json::from_str(&json).unwrap(); + assert_eq!(back.provider.provider, cfg.provider.provider); + assert_eq!(back.output.format, cfg.output.format); + } + + // โ”€โ”€โ”€ Config::default() new fields โ”€โ”€โ”€ + + #[test] + fn config_default_response_format_none() { + let cfg = Config::default(); + assert_eq!(cfg.response_format, "none"); + } + + #[test] + fn config_default_system_prompt_overrides_none() { + let cfg = Config::default(); + assert!(cfg.review_system_prompt_override.is_none()); + assert!(cfg.review_system_prompt_file.is_none()); + assert!(cfg.scan_system_prompt_override.is_none()); + assert!(cfg.scan_system_prompt_file.is_none()); + } + + // โ”€โ”€โ”€ ReviewSection parsing and merge โ”€โ”€โ”€ + + #[test] + fn parse_review_section_with_response_format() { + let yaml = r" +review: + response_format: json_object +"; + let cora = CoraFile::from_str(yaml).unwrap(); + assert_eq!( + cora.review.as_ref().unwrap().response_format.as_deref(), + Some("json_object") + ); + } + + #[test] + fn parse_review_section_with_system_prompt() { + let yaml = r" +review: + system_prompt: | + You are a security-focused reviewer. + system_prompt_file: .cora/prompts/review.md +"; + let cora = CoraFile::from_str(yaml).unwrap(); + assert_eq!( + cora.review.as_ref().unwrap().system_prompt.as_deref(), + Some("You are a security-focused reviewer.\n") + ); + assert_eq!( + cora.review.as_ref().unwrap().system_prompt_file.as_deref(), + Some(".cora/prompts/review.md") + ); + } + + #[test] + fn merge_review_response_format() { + let mut cfg = Config::default(); + let cora = CoraFile { + review: Some(ReviewSection { + response_format: Some("json_object".to_string()), + system_prompt: None, + system_prompt_file: None, + static_analysis: None, + context_chain: None, + }), + ..Default::default() + }; + cora.merge_into(&mut cfg); + assert_eq!(cfg.response_format, "json_object"); + } + + #[test] + fn merge_review_system_prompt() { + let mut cfg = Config::default(); + let cora = CoraFile { + review: Some(ReviewSection { + response_format: None, + system_prompt: Some("Custom prompt here.".to_string()), + system_prompt_file: None, + static_analysis: None, + context_chain: None, + }), + ..Default::default() + }; + cora.merge_into(&mut cfg); + assert_eq!( + cfg.review_system_prompt_override.as_deref(), + Some("Custom prompt here.") + ); + } + + #[test] + fn merge_review_system_prompt_file() { + let mut cfg = Config::default(); + let cora = CoraFile { + review: Some(ReviewSection { + response_format: None, + system_prompt: None, + system_prompt_file: Some("prompts/review.md".to_string()), + static_analysis: None, + context_chain: None, + }), + ..Default::default() + }; + cora.merge_into(&mut cfg); + assert_eq!( + cfg.review_system_prompt_file.as_deref(), + Some("prompts/review.md") + ); + } + + // โ”€โ”€โ”€ ScanSection parsing and merge โ”€โ”€โ”€ + + #[test] + fn parse_scan_section_with_system_prompt() { + let yaml = r" +scan: + system_prompt: | + You are a performance-focused scanner. +"; + let cora = CoraFile::from_str(yaml).unwrap(); + assert_eq!( + cora.scan.as_ref().unwrap().system_prompt.as_deref(), + Some("You are a performance-focused scanner.\n") + ); + } + + #[test] + fn merge_scan_system_prompt() { + let mut cfg = Config::default(); + let cora = CoraFile { + scan: Some(ScanSection { + system_prompt: Some("Performance only.".to_string()), + system_prompt_file: None, + }), + ..Default::default() + }; + cora.merge_into(&mut cfg); + assert_eq!( + cfg.scan_system_prompt_override.as_deref(), + Some("Performance only.") + ); + } + + // โ”€โ”€โ”€ Full .cora.yaml with review and scan sections โ”€โ”€โ”€ + + #[test] + fn parse_cora_file_with_review_and_scan() { + let yaml = r" +review: + response_format: json_object + system_prompt: | + Security only. +scan: + system_prompt: | + Performance only. + system_prompt_file: scan.md +"; + let cora = CoraFile::from_str(yaml).unwrap(); + let review = cora.review.unwrap(); + assert_eq!(review.response_format.as_deref(), Some("json_object")); + assert_eq!(review.system_prompt.as_deref(), Some("Security only.\n")); + let scan = cora.scan.unwrap(); + assert_eq!(scan.system_prompt.as_deref(), Some("Performance only.\n")); + assert_eq!(scan.system_prompt_file.as_deref(), Some("scan.md")); + } + + // โ”€โ”€โ”€ LLM section parsing and merge โ”€โ”€โ”€ + + #[test] + fn config_default_temperature_is_zero() { + let cfg = Config::default(); + assert_eq!(cfg.temperature, 0.0); + } + + #[test] + fn config_default_max_tokens() { + let cfg = Config::default(); + assert_eq!(cfg.max_tokens, 4096); + } + + #[test] + fn config_default_timeout() { + let cfg = Config::default(); + assert_eq!(cfg.timeout, 120); + } + + #[test] + fn config_default_cache_ttl() { + let cfg = Config::default(); + assert_eq!(cfg.cache_ttl, 1440); + } + + #[test] + fn parse_llm_section() { + let yaml = r" +llm: + temperature: 0.5 + max_tokens: 8192 + timeout: 60 + cache_ttl: 720 +"; + let cora = CoraFile::from_str(yaml).unwrap(); + let llm = cora.llm.unwrap(); + assert_eq!(llm.temperature, Some(0.5)); + assert_eq!(llm.max_tokens, Some(8192)); + assert_eq!(llm.timeout, Some(60)); + assert_eq!(llm.cache_ttl, Some(720)); + } + + #[test] + fn parse_llm_section_partial() { + let yaml = r" +llm: + temperature: 0.3 +"; + let cora = CoraFile::from_str(yaml).unwrap(); + let llm = cora.llm.unwrap(); + assert_eq!(llm.temperature, Some(0.3)); + assert_eq!(llm.max_tokens, None); + assert_eq!(llm.timeout, None); + assert_eq!(llm.cache_ttl, None); + } + + #[test] + fn merge_llm_temperature() { + let mut cfg = Config::default(); + let cora = CoraFile { + llm: Some(LlmSection { + temperature: Some(0.7), + max_tokens: None, + timeout: None, + cache_ttl: None, + }), + ..Default::default() + }; + cora.merge_into(&mut cfg); + assert_eq!(cfg.temperature, 0.7); + // Other LLM fields should remain at defaults + assert_eq!(cfg.max_tokens, 4096); + assert_eq!(cfg.timeout, 120); + assert_eq!(cfg.cache_ttl, 1440); + } + + #[test] + fn merge_llm_max_tokens() { + let mut cfg = Config::default(); + let cora = CoraFile { + llm: Some(LlmSection { + temperature: None, + max_tokens: Some(2048), + timeout: None, + cache_ttl: None, + }), + ..Default::default() + }; + cora.merge_into(&mut cfg); + assert_eq!(cfg.max_tokens, 2048); + } + + #[test] + fn merge_llm_timeout() { + let mut cfg = Config::default(); + let cora = CoraFile { + llm: Some(LlmSection { + temperature: None, + max_tokens: None, + timeout: Some(300), + cache_ttl: None, + }), + ..Default::default() + }; + cora.merge_into(&mut cfg); + assert_eq!(cfg.timeout, 300); + } + + #[test] + fn merge_llm_all_fields() { + let mut cfg = Config::default(); + let cora = CoraFile { + llm: Some(LlmSection { + temperature: Some(1.0), + max_tokens: Some(16384), + timeout: Some(240), + cache_ttl: Some(2880), + }), + ..Default::default() + }; + cora.merge_into(&mut cfg); + assert_eq!(cfg.temperature, 1.0); + assert_eq!(cfg.max_tokens, 16384); + assert_eq!(cfg.timeout, 240); + assert_eq!(cfg.cache_ttl, 2880); + } + + // โ”€โ”€โ”€ Config error on malformed YAML โ”€โ”€โ”€ + + #[test] + fn cora_file_malformed_yaml_returns_error() { + let yaml = r" +provider: + provider: openai + model: gpt-4 + this is not valid yaml: [ +"; + let result = CoraFile::from_str(yaml); + assert!(result.is_err(), "malformed YAML should return an error"); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("config parse error"), + "error message should mention parse failure: {err}" + ); + } + + #[test] + fn cora_file_empty_yaml_is_ok() { + let cora = CoraFile::from_str("").unwrap(); + assert!(cora.llm.is_none()); + assert!(cora.provider.is_none()); + } + + #[test] + fn cora_file_yaml_roundtrip_with_llm() { + let cora = CoraFile { + llm: Some(LlmSection { + temperature: Some(0.5), + max_tokens: Some(8192), + timeout: Some(60), + cache_ttl: None, + }), + ..Default::default() + }; + let yaml = serde_yaml_ng::to_string(&cora).unwrap(); + let back: CoraFile = serde_yaml_ng::from_str(&yaml).unwrap(); + assert_eq!(back.llm.as_ref().unwrap().temperature, Some(0.5)); + assert_eq!(back.llm.as_ref().unwrap().max_tokens, Some(8192)); + } + + // โ”€โ”€โ”€ BundlingSection parsing and merge โ”€โ”€โ”€ + + #[test] + fn config_default_bundling() { + let cfg = Config::default(); + assert_eq!(cfg.bundling.max_chars_per_group, 60_000); + assert_eq!(cfg.bundling.max_files_per_group, 20); + assert_eq!( + cfg.bundling.strategy, + crate::engine::bundling::GroupingStrategy::Smart + ); + assert!(cfg.bundling.coalesce_by_directory); + assert!(cfg.bundling.coalesce_by_language); + } + + #[test] + fn parse_bundling_section_full() { + let yaml = r" +bundling: + max_chars_per_group: 30000 + max_files_per_group: 10 + strategy: flat + coalesce_by_directory: false + coalesce_by_language: false +"; + let cora = CoraFile::from_str(yaml).unwrap(); + let b = cora.bundling.unwrap(); + assert_eq!(b.max_chars_per_group, Some(30_000)); + assert_eq!(b.max_files_per_group, Some(10)); + assert_eq!( + b.strategy, + Some(crate::engine::bundling::GroupingStrategy::Flat) + ); + assert_eq!(b.coalesce_by_directory, Some(false)); + assert_eq!(b.coalesce_by_language, Some(false)); + } + + #[test] + fn parse_bundling_section_partial() { + let yaml = r" +bundling: + max_chars_per_group: 40000 + strategy: flat +"; + let cora = CoraFile::from_str(yaml).unwrap(); + let b = cora.bundling.unwrap(); + assert_eq!(b.max_chars_per_group, Some(40_000)); + assert_eq!(b.max_files_per_group, None); + assert_eq!( + b.strategy, + Some(crate::engine::bundling::GroupingStrategy::Flat) + ); + assert_eq!(b.coalesce_by_directory, None); + assert_eq!(b.coalesce_by_language, None); + } + + #[test] + fn merge_bundling_all_fields() { + let mut cfg = Config::default(); + let cora = CoraFile { + bundling: Some(BundlingSection { + max_chars_per_group: Some(30_000), + max_files_per_group: Some(10), + strategy: Some(crate::engine::bundling::GroupingStrategy::Flat), + coalesce_by_directory: Some(false), + coalesce_by_language: Some(false), + }), + ..Default::default() + }; + cora.merge_into(&mut cfg); + assert_eq!(cfg.bundling.max_chars_per_group, 30_000); + assert_eq!(cfg.bundling.max_files_per_group, 10); + assert_eq!( + cfg.bundling.strategy, + crate::engine::bundling::GroupingStrategy::Flat + ); + assert!(!cfg.bundling.coalesce_by_directory); + assert!(!cfg.bundling.coalesce_by_language); + } + + #[test] + fn merge_bundling_partial() { + let mut cfg = Config::default(); + let cora = CoraFile { + bundling: Some(BundlingSection { + max_chars_per_group: Some(40_000), + max_files_per_group: None, + strategy: None, + coalesce_by_directory: None, + coalesce_by_language: Some(false), + }), + ..Default::default() + }; + cora.merge_into(&mut cfg); + assert_eq!(cfg.bundling.max_chars_per_group, 40_000); + assert_eq!(cfg.bundling.max_files_per_group, 20); // default unchanged + assert_eq!( + cfg.bundling.strategy, + crate::engine::bundling::GroupingStrategy::Smart + ); // default unchanged + assert!(cfg.bundling.coalesce_by_directory); // default unchanged + assert!(!cfg.bundling.coalesce_by_language); + } + + #[test] + fn merge_bundling_absent_leaves_defaults() { + let mut cfg = Config::default(); + let cora = CoraFile { + ..Default::default() + }; + cora.merge_into(&mut cfg); + assert_eq!(cfg.bundling.max_chars_per_group, 60_000); + assert_eq!(cfg.bundling.max_files_per_group, 20); + } + + #[test] + fn bundling_section_yaml_roundtrip() { + let section = BundlingSection { + max_chars_per_group: Some(50_000), + max_files_per_group: Some(15), + strategy: Some(crate::engine::bundling::GroupingStrategy::Smart), + coalesce_by_directory: Some(true), + coalesce_by_language: Some(false), + }; + let yaml = serde_yaml_ng::to_string(§ion).unwrap(); + let back: BundlingSection = serde_yaml_ng::from_str(&yaml).unwrap(); + assert_eq!(back.max_chars_per_group, Some(50_000)); + assert_eq!(back.max_files_per_group, Some(15)); + assert_eq!( + back.strategy, + Some(crate::engine::bundling::GroupingStrategy::Smart) + ); } } diff --git a/src/engine/bundling/grouping.rs b/src/engine/bundling/grouping.rs new file mode 100644 index 00000000..01c8658f --- /dev/null +++ b/src/engine/bundling/grouping.rs @@ -0,0 +1,626 @@ +#[allow(dead_code)] +/// Grouping strategies for bundling files into LLM-friendly groups. +use std::collections::HashMap; + +use tracing::debug; + +use crate::engine::scanner::FileEntry; + +use super::types::{BundlingConfig, FileGroup, GroupingKey, GroupingStrategy, LanguageFamily}; + +/// Group files into `FileGroup`s based on the configuration. +/// +/// This is the main entry point for the bundling engine. It dispatches to +/// the appropriate strategy and returns a vector of groups. +pub fn group_files(files: &[FileEntry], config: &BundlingConfig) -> Vec { + if files.is_empty() { + return Vec::new(); + } + + match config.strategy { + GroupingStrategy::Smart => group_smart(files, config), + GroupingStrategy::Flat => group_flat(files, config), + } +} + +/// Smart grouping: coalesce by directory and language within character limits. +/// +/// Algorithm: +/// 1. Compute a `GroupingKey` (directory ร— language_family) for each file. +/// 2. Sort files by key so related files are adjacent. +/// 3. Greedily pack files into groups, preferring to keep the same key together. +/// 4. When a group is full (char or file limit), start a new one. +fn group_smart(files: &[FileEntry], config: &BundlingConfig) -> Vec { + debug!(total_files = files.len(), "smart grouping files"); + + let max_chars = config.max_chars_per_group; + let max_files = config.max_files_per_group; + + // Build grouping keys for each file + let mut keyed: Vec<(GroupingKey, &FileEntry)> = files + .iter() + .map(|f| { + let ext = f + .path + .rsplit_once('.') + .map(|(_, e)| e) + .filter(|e| !e.contains('/')); + let key = if config.coalesce_by_directory || config.coalesce_by_language { + GroupingKey::from_file(&f.path, ext) + } else { + GroupingKey { + directory: String::new(), + language: LanguageFamily::Other, + } + }; + (key, f) + }) + .collect(); + + // Sort by key to cluster related files + keyed.sort_by(|a, b| { + if config.coalesce_by_directory && config.coalesce_by_language { + a.0.directory + .cmp(&b.0.directory) + .then_with(|| a.0.language.cmp(&b.0.language)) + .then_with(|| a.1.path.cmp(&b.1.path)) + } else if config.coalesce_by_directory { + a.0.directory + .cmp(&b.0.directory) + .then_with(|| a.1.path.cmp(&b.1.path)) + } else if config.coalesce_by_language { + a.0.language + .cmp(&b.0.language) + .then_with(|| a.1.path.cmp(&b.1.path)) + } else { + a.1.path.cmp(&b.1.path) + } + }); + + // Greedy packing with coalescing preference + let mut groups: Vec = Vec::new(); + let mut current_group = FileGroup::new(); + let mut current_key: Option = None; + + for (key, file) in keyed { + let fits = current_group.would_fit(file, max_chars, max_files); + + // If file doesn't fit or we're switching to a very different key, consider + // starting a new group. But only split if we have enough files in the current + // group to justify it (avoid trivial groups). + let should_split = !fits + || (current_key.is_some() + && current_key.as_ref() != Some(&key) + && current_group.len() >= 2 + && would_exceed_threshold(¤t_group, file, max_chars)); + + if should_split && !current_group.is_empty() { + groups.push(std::mem::replace(&mut current_group, FileGroup::new())); + let _ = current_key.take(); + } + + current_group.push(file.clone()); + current_key = Some(key); + } + + if !current_group.is_empty() { + groups.push(current_group); + } + + debug!(groups = groups.len(), "smart grouping complete"); + + groups +} + +/// Check if adding a file would push the group past a "comfort threshold" +/// (75% of max_chars), suggesting it's a good split point. +fn would_exceed_threshold(group: &FileGroup, file: &FileEntry, max_chars: usize) -> bool { + let threshold = (max_chars as f64 * 0.75) as usize; + let cost = FileGroup::file_cost(file); + group.total_chars + cost > threshold +} + +/// Flat grouping: simple first-fit batching by character count. +/// +/// This is the legacy behavior โ€” equivalent to the old `batch_files` function +/// in scanner.rs. Files are processed in order, packed greedily until limits +/// are hit, then a new group starts. +fn group_flat(files: &[FileEntry], config: &BundlingConfig) -> Vec { + debug!(total_files = files.len(), "flat grouping files"); + + let max_chars = config.max_chars_per_group; + let max_files = config.max_files_per_group; + + let mut groups: Vec = Vec::new(); + let mut current_group = FileGroup::new(); + + for file in files { + if !current_group.would_fit(file, max_chars, max_files) && !current_group.is_empty() { + groups.push(std::mem::replace(&mut current_group, FileGroup::new())); + } + current_group.push(file.clone()); + } + + if !current_group.is_empty() { + groups.push(current_group); + } + + debug!(groups = groups.len(), "flat grouping complete"); + + groups +} + +/// Group files by a specific key dimension (directory or language only). +/// +/// This is a helper for more fine-grained grouping when the user only wants +/// one dimension of coalescing. +pub fn group_by_dimension( + files: &[FileEntry], + config: &BundlingConfig, + dimension: GroupingDimension, +) -> Vec { + let max_chars = config.max_chars_per_group; + let max_files = config.max_files_per_group; + + // Bucket files by the chosen dimension + let mut buckets: HashMap> = HashMap::new(); + + for file in files { + let bucket_key = match dimension { + GroupingDimension::Directory => file + .path + .rsplit_once('/') + .or_else(|| file.path.rsplit_once('\\')) + .map(|(dir, _)| dir.to_string()) + .unwrap_or_else(|| ".".to_string()), + GroupingDimension::Language => file + .path + .rsplit_once('.') + .map(|(_, ext)| LanguageFamily::from_extension(ext).to_string()) + .unwrap_or_else(|| "other".to_string()), + }; + buckets.entry(bucket_key).or_default().push(file); + } + + // Sort bucket keys for deterministic output + let mut bucket_keys: Vec = buckets.keys().cloned().collect(); + bucket_keys.sort(); + + // Pack each bucket into groups respecting limits + let mut groups: Vec = Vec::new(); + + for key in bucket_keys { + let bucket_files = &buckets[&key]; + let mut current_group = FileGroup::new(); + + for file in bucket_files { + if !current_group.would_fit(file, max_chars, max_files) && !current_group.is_empty() { + groups.push(std::mem::replace(&mut current_group, FileGroup::new())); + } + current_group.push((*file).clone()); + } + + if !current_group.is_empty() { + groups.push(current_group); + } + } + + groups +} + +/// Dimension to group files by. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GroupingDimension { + /// Group by parent directory. + Directory, + /// Group by language family. + Language, +} + +/// Merge small groups that could fit together. +/// +/// After initial grouping, some groups may be very small (1-2 files). +/// This post-processing step merges adjacent small groups if their +/// combined size stays within limits. +pub fn merge_small_groups( + mut groups: Vec, + config: &BundlingConfig, + min_group_size: usize, +) -> Vec { + if groups.len() <= 1 { + return groups; + } + + let max_chars = config.max_chars_per_group; + let max_files = config.max_files_per_group; + + let mut merged: Vec = Vec::new(); + let mut accumulator = groups.remove(0); + + for group in groups { + // Merge if both groups are small and combined fits + if accumulator.len() < min_group_size + && group.len() < min_group_size + && accumulator.len() + group.len() <= max_files + && accumulator.total_chars + group.total_chars <= max_chars + { + for file in group.files { + accumulator.push(file); + } + } else { + merged.push(std::mem::replace(&mut accumulator, group)); + } + } + + if !accumulator.is_empty() { + merged.push(accumulator); + } + + merged +} + +/// Collect statistics about the grouping result. +#[derive(Debug, Clone)] +pub struct GroupingStats { + /// Total number of groups. + pub group_count: usize, + /// Total number of files across all groups. + pub total_files: usize, + /// Total characters across all groups. + pub total_chars: usize, + /// Average files per group. + pub avg_files_per_group: f64, + /// Average characters per group. + pub avg_chars_per_group: f64, + /// Number of groups with only 1 file. + pub single_file_groups: usize, +} + +impl GroupingStats { + /// Compute stats from a list of groups. + pub fn from_groups(groups: &[FileGroup]) -> Self { + let group_count = groups.len(); + let total_files: usize = groups.iter().map(|g| g.len()).sum(); + let total_chars: usize = groups.iter().map(|g| g.total_chars).sum(); + let single_file_groups = groups.iter().filter(|g| g.len() == 1).count(); + + Self { + group_count, + total_files, + total_chars, + avg_files_per_group: if group_count > 0 { + total_files as f64 / group_count as f64 + } else { + 0.0 + }, + avg_chars_per_group: if group_count > 0 { + total_chars as f64 / group_count as f64 + } else { + 0.0 + }, + single_file_groups, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_file(path: &str, content_len: usize) -> FileEntry { + FileEntry { + path: path.to_string(), + content: "x".repeat(content_len), + lines: content_len, + } + } + + fn default_config() -> BundlingConfig { + BundlingConfig::default() + } + + // โ”€โ”€โ”€ group_files dispatch โ”€โ”€โ”€ + + #[test] + fn group_files_empty() { + let groups = group_files(&[], &default_config()); + assert!(groups.is_empty()); + } + + // โ”€โ”€โ”€ Flat grouping โ”€โ”€โ”€ + + #[test] + fn flat_grouping_basic() { + let files = vec![ + make_file("a.rs", 100), + make_file("b.rs", 100), + make_file("c.rs", 100), + ]; + let config = BundlingConfig { + max_chars_per_group: 250, // fits ~2 files per group + max_files_per_group: 100, + strategy: GroupingStrategy::Flat, + ..Default::default() + }; + + let groups = group_files(&files, &config); + assert!(groups.len() >= 2, "should split into at least 2 groups"); + assert_eq!( + groups.iter().map(|g| g.len()).sum::(), + 3, + "all files should be assigned" + ); + } + + #[test] + fn flat_grouping_all_fit() { + let files = vec![make_file("a.rs", 10), make_file("b.rs", 10)]; + let config = BundlingConfig { + strategy: GroupingStrategy::Flat, + ..Default::default() + }; + + let groups = group_files(&files, &config); + assert_eq!(groups.len(), 1); + assert_eq!(groups[0].len(), 2); + } + + #[test] + fn flat_grouping_preserves_order() { + let files = vec![ + make_file("a.rs", 10), + make_file("b.rs", 10), + make_file("c.rs", 10), + ]; + let config = BundlingConfig { + strategy: GroupingStrategy::Flat, + ..Default::default() + }; + + let groups = group_files(&files, &config); + assert_eq!(groups.len(), 1); + assert_eq!(groups[0].files[0].path, "a.rs"); + assert_eq!(groups[0].files[1].path, "b.rs"); + assert_eq!(groups[0].files[2].path, "c.rs"); + } + + #[test] + fn flat_grouping_max_files_limit() { + let files = vec![ + make_file("a.rs", 10), + make_file("b.rs", 10), + make_file("c.rs", 10), + make_file("d.rs", 10), + ]; + let config = BundlingConfig { + strategy: GroupingStrategy::Flat, + max_files_per_group: 2, + max_chars_per_group: 1_000_000, + ..Default::default() + }; + + let groups = group_files(&files, &config); + assert_eq!(groups.len(), 2); + assert_eq!(groups[0].len(), 2); + assert_eq!(groups[1].len(), 2); + } + + // โ”€โ”€โ”€ Smart grouping โ”€โ”€โ”€ + + #[test] + fn smart_grouping_clusters_same_directory() { + let files = vec![ + make_file("src/engine/mod.rs", 100), + make_file("src/engine/types.rs", 100), + make_file("src/config/mod.rs", 100), + make_file("tests/integration.rs", 100), + ]; + let config = BundlingConfig { + strategy: GroupingStrategy::Smart, + coalesce_by_directory: true, + coalesce_by_language: false, + ..Default::default() + }; + + let groups = group_files(&files, &config); + // Should cluster src/engine files together and src/config + tests may be separate + assert_eq!( + groups.iter().map(|g| g.len()).sum::(), + 4, + "all files assigned" + ); + } + + #[test] + fn smart_grouping_clusters_same_language() { + let files = vec![ + make_file("src/main.rs", 100), + make_file("lib/api.py", 100), + make_file("src/lib.rs", 100), + make_file("scripts/deploy.py", 100), + ]; + let config = BundlingConfig { + strategy: GroupingStrategy::Smart, + coalesce_by_directory: false, + coalesce_by_language: true, + ..Default::default() + }; + + let groups = group_files(&files, &config); + // Rust files should cluster together, Python files together + assert_eq!( + groups.iter().map(|g| g.len()).sum::(), + 4, + "all files assigned" + ); + } + + #[test] + fn smart_grouping_respects_char_limit() { + let files = vec![ + make_file("src/a.rs", 10_000), + make_file("src/b.rs", 10_000), + make_file("src/c.rs", 10_000), + ]; + let config = BundlingConfig { + strategy: GroupingStrategy::Smart, + max_chars_per_group: 15_000, + max_files_per_group: 100, + ..Default::default() + }; + + let groups = group_files(&files, &config); + assert!(groups.len() >= 2, "should split into at least 2 groups"); + } + + // โ”€โ”€โ”€ merge_small_groups โ”€โ”€โ”€ + + #[test] + fn merge_small_groups_basic() { + let g1 = { + let mut g = FileGroup::new(); + g.push(make_file("a.rs", 10)); + g + }; + let g2 = { + let mut g = FileGroup::new(); + g.push(make_file("b.rs", 10)); + g + }; + let g3 = { + let mut g = FileGroup::new(); + g.push(make_file("c.rs", 10)); + g + }; + + let merged = merge_small_groups(vec![g1, g2, g3], &default_config(), 2); + // All groups have 1 file (< min_group_size=2), so they should be merged + assert!(merged.len() <= 2, "should merge small groups"); + assert_eq!( + merged.iter().map(|g| g.len()).sum::(), + 3, + "no files lost" + ); + } + + #[test] + fn merge_small_groups_respects_limits() { + let g1 = { + let mut g = FileGroup::new(); + g.push(make_file("a.rs", 30_000)); + g + }; + let g2 = { + let mut g = FileGroup::new(); + g.push(make_file("b.rs", 30_000)); + g + }; + + let config = BundlingConfig { + max_chars_per_group: 50_000, + ..Default::default() + }; + let merged = merge_small_groups(vec![g1, g2], &config, 2); + // 30k + 30k = 60k > 50k, should not merge + assert_eq!(merged.len(), 2); + } + + // โ”€โ”€โ”€ group_by_dimension โ”€โ”€โ”€ + + #[test] + fn group_by_directory() { + let files = vec![ + make_file("src/engine/mod.rs", 10), + make_file("src/engine/types.rs", 10), + make_file("src/config/mod.rs", 10), + ]; + let groups = group_by_dimension(&files, &default_config(), GroupingDimension::Directory); + // Should have at least 2 groups: src/engine and src/config + assert!(groups.len() >= 2); + } + + #[test] + fn group_by_language() { + let files = vec![ + make_file("src/main.rs", 10), + make_file("src/app.py", 10), + make_file("src/lib.rs", 10), + ]; + let groups = group_by_dimension(&files, &default_config(), GroupingDimension::Language); + // Should have at least 2 groups: Rust and Python + assert!(groups.len() >= 2); + } + + // โ”€โ”€โ”€ GroupingStats โ”€โ”€โ”€ + + #[test] + fn grouping_stats_empty() { + let stats = GroupingStats::from_groups(&[]); + assert_eq!(stats.group_count, 0); + assert_eq!(stats.total_files, 0); + assert_eq!(stats.total_chars, 0); + assert_eq!(stats.single_file_groups, 0); + } + + #[test] + fn grouping_stats_computed() { + let g1 = { + let mut g = FileGroup::new(); + g.push(make_file("a.rs", 100)); + g.push(make_file("b.rs", 100)); + g + }; + let g2 = { + let mut g = FileGroup::new(); + g.push(make_file("c.rs", 50)); + g + }; + + let stats = GroupingStats::from_groups(&[g1, g2]); + assert_eq!(stats.group_count, 2); + assert_eq!(stats.total_files, 3); + assert_eq!(stats.single_file_groups, 1); + assert!((stats.avg_files_per_group - 1.5).abs() < 0.01); + } + + // โ”€โ”€โ”€ Smart grouping with both coalesce off โ”€โ”€โ”€ + + #[test] + fn smart_grouping_no_coalesce_is_like_flat() { + let files = vec![ + make_file("z.rs", 10), + make_file("a.rs", 10), + make_file("m.rs", 10), + ]; + let config = BundlingConfig { + strategy: GroupingStrategy::Smart, + coalesce_by_directory: false, + coalesce_by_language: false, + ..Default::default() + }; + + let groups = group_files(&files, &config); + assert_eq!(groups.len(), 1); + // Without coalescing, files are sorted by path + assert_eq!(groups[0].files[0].path, "a.rs"); + assert_eq!(groups[0].files[1].path, "m.rs"); + assert_eq!(groups[0].files[2].path, "z.rs"); + } + + // โ”€โ”€โ”€ Single large file โ”€โ”€โ”€ + + #[test] + fn single_large_file_gets_own_group() { + let files = vec![make_file("huge.rs", 200_000)]; + let config = BundlingConfig { + strategy: GroupingStrategy::Smart, + max_chars_per_group: 50_000, + max_files_per_group: 10, + ..Default::default() + }; + + let groups = group_files(&files, &config); + assert_eq!(groups.len(), 1); + assert_eq!(groups[0].len(), 1); + } +} diff --git a/src/engine/bundling/mod.rs b/src/engine/bundling/mod.rs new file mode 100644 index 00000000..98004017 --- /dev/null +++ b/src/engine/bundling/mod.rs @@ -0,0 +1,33 @@ +#[allow(dead_code, unused)] +/// File bundling engine โ€” groups files into cohesive units for LLM review. +/// +/// Instead of arbitrary batching by character count alone, the bundling engine +/// groups related files together (by directory, language, or both) to provide +/// better review context to the LLM. +/// +/// # Configuration +/// +/// Controlled via `BundlingConfig` (on `Config`): +/// - `strategy`: `"smart"` (coalesce by directory/language) or `"flat"` (legacy) +/// - `max_chars_per_group`: character budget per group +/// - `max_files_per_group`: file count limit per group +/// - `coalesce_by_directory`: prefer keeping same-directory files together +/// - `coalesce_by_language`: prefer keeping same-language files together +/// +/// # Usage +/// +/// ```ignore +/// use crate::engine::bundling::{group_files, types::BundlingConfig}; +/// +/// let config = BundlingConfig::default(); +/// let groups = group_files(&files, &config); +/// for group in groups { +/// let content = crate::engine::scanner::format_batch_for_prompt(&group.files); +/// // send content to LLM +/// } +/// ``` +pub mod grouping; +pub mod types; + +#[allow(dead_code, unused)] +pub use types::GroupingStrategy; diff --git a/src/engine/bundling/types.rs b/src/engine/bundling/types.rs new file mode 100644 index 00000000..51ba395c --- /dev/null +++ b/src/engine/bundling/types.rs @@ -0,0 +1,450 @@ +#[allow(dead_code)] +/// Bundling types โ€” configuration and data structures for file grouping. +use serde::{Deserialize, Serialize}; + +/// Runtime configuration for the file bundling engine (lives on `Config`). +/// +/// Controls how files are grouped before being sent to the LLM for review. +/// Smaller, cohesive groups give better review context than arbitrary batching +/// by character count alone. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BundlingConfig { + /// Maximum number of characters per group (token budget). + /// Files are grouped until this limit is approached. + pub max_chars_per_group: usize, + /// Maximum number of files per group. + pub max_files_per_group: usize, + /// Preferred grouping strategy. + /// + /// - `"smart"` (default): group by directory, then by language, respecting + /// character limits. Keeps related files together. + /// - `"flat"`: simple first-fit batching by character count (legacy behavior, + /// equivalent to the old `batch_files` function). + #[serde(default = "default_grouping_strategy")] + pub strategy: GroupingStrategy, + /// Whether to keep files from the same directory together. + /// When `true`, files in the same directory are prioritized for the same group. + /// When `false`, groups may mix files from different directories. + #[serde(default = "default_true")] + pub coalesce_by_directory: bool, + /// Whether to group files by language (extension family). + /// When `true`, files with similar extensions (e.g., `.rs` + `.toml`) are + /// preferred for the same group. + #[serde(default = "default_true")] + pub coalesce_by_language: bool, +} + +impl Default for BundlingConfig { + fn default() -> Self { + Self { + max_chars_per_group: 60_000, + max_files_per_group: 20, + strategy: default_grouping_strategy(), + coalesce_by_directory: true, + coalesce_by_language: true, + } + } +} + +fn default_grouping_strategy() -> GroupingStrategy { + GroupingStrategy::Smart +} + +fn default_true() -> bool { + true +} + +/// Grouping strategy for file bundling. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum GroupingStrategy { + /// Smart grouping: coalesce by directory and language within limits. + Smart, + /// Flat batching: first-fit by character count (legacy). + Flat, +} + +impl std::fmt::Display for GroupingStrategy { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + GroupingStrategy::Smart => write!(f, "smart"), + GroupingStrategy::Flat => write!(f, "flat"), + } + } +} + +/// A group of files to be reviewed together in a single LLM call. +/// +/// Each group is a coherent unit โ€” ideally files that are related by +/// directory, language, or feature area โ€” kept within character and file +/// count limits. +#[derive(Debug, Clone)] +pub struct FileGroup { + /// Files in this group. + pub files: Vec, + /// Total character count (content + path headers + line numbers). + pub total_chars: usize, +} + +impl FileGroup { + /// Create a new empty group. + pub fn new() -> Self { + Self { + files: Vec::new(), + total_chars: 0, + } + } + + /// Estimated character size for a single file entry (content + header overhead). + pub fn file_cost(file: &crate::engine::scanner::FileEntry) -> usize { + file.content.len() + file.path.len() + 20 // header overhead + } + + /// Check if adding a file would exceed the limits. + pub fn would_fit( + &self, + file: &crate::engine::scanner::FileEntry, + max_chars: usize, + max_files: usize, + ) -> bool { + if self.files.len() >= max_files { + return false; + } + let cost = Self::file_cost(file); + // Allow first file even if it exceeds max_chars (single large file) + if self.files.is_empty() { + return true; + } + self.total_chars + cost <= max_chars + } + + /// Add a file to the group. + pub fn push(&mut self, file: crate::engine::scanner::FileEntry) { + self.total_chars += Self::file_cost(&file); + self.files.push(file); + } + + /// Number of files in the group. + pub fn len(&self) -> usize { + self.files.len() + } + + /// Whether the group is empty. + pub fn is_empty(&self) -> bool { + self.files.is_empty() + } +} + +/// Language family classification for grouping. +/// +/// Files with extensions in the same family are preferred to be grouped together, +/// as they often belong to the same project layer. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LanguageFamily { + /// Rust (rs) + Rust, + /// JavaScript/TypeScript (js, ts, tsx, jsx, mjs, cjs) + JavaScript, + /// Python (py, pyi) + Python, + /// Go (go, mod, sum) + Go, + /// Java/JVM (java, kt, scala, gradle) + Jvm, + /// Web (html, css, scss, less, vue, svelte) + Web, + /// C/C++ (c, cpp, h, hpp, cc, cxx) + CFamily, + /// Shell (sh, bash, zsh, ps1) + Shell, + /// Config (yaml, yml, json, toml, ini) + Config, + /// Documentation (md, rst, txt, adoc) + Documentation, + /// Database (sql, graphql, proto) + Database, + /// Other / unclassified + Other, +} + +impl LanguageFamily { + /// Classify a file extension into a language family. + pub fn from_extension(ext: &str) -> Self { + match ext.to_lowercase().as_str() { + "rs" => LanguageFamily::Rust, + "js" | "ts" | "tsx" | "jsx" | "mjs" | "cjs" => LanguageFamily::JavaScript, + "py" | "pyi" => LanguageFamily::Python, + "go" | "mod" | "sum" => LanguageFamily::Go, + "java" | "kt" | "scala" | "gradle" => LanguageFamily::Jvm, + "html" | "css" | "scss" | "less" | "vue" | "svelte" => LanguageFamily::Web, + "c" | "cpp" | "h" | "hpp" | "cc" | "cxx" => LanguageFamily::CFamily, + "sh" | "bash" | "zsh" | "ps1" => LanguageFamily::Shell, + "yaml" | "yml" | "json" | "toml" | "ini" => LanguageFamily::Config, + "md" | "rst" | "txt" | "adoc" => LanguageFamily::Documentation, + "sql" | "graphql" | "proto" => LanguageFamily::Database, + _ => LanguageFamily::Other, + } + } +} + +impl std::fmt::Display for LanguageFamily { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + LanguageFamily::Rust => write!(f, "rust"), + LanguageFamily::JavaScript => write!(f, "javascript"), + LanguageFamily::Python => write!(f, "python"), + LanguageFamily::Go => write!(f, "go"), + LanguageFamily::Jvm => write!(f, "jvm"), + LanguageFamily::Web => write!(f, "web"), + LanguageFamily::CFamily => write!(f, "c_family"), + LanguageFamily::Shell => write!(f, "shell"), + LanguageFamily::Config => write!(f, "config"), + LanguageFamily::Documentation => write!(f, "documentation"), + LanguageFamily::Database => write!(f, "database"), + LanguageFamily::Other => write!(f, "other"), + } + } +} + +/// Key used for grouping files together. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct GroupingKey { + /// The parent directory of the file (relative to project root). + pub directory: String, + /// The language family of the file. + pub language: LanguageFamily, +} + +impl GroupingKey { + /// Create a grouping key from a file path and extension. + pub fn from_file(path: &str, extension: Option<&str>) -> Self { + let directory = path + .rsplit_once('/') + .or_else(|| path.rsplit_once('\\')) + .map(|(dir, _)| dir.to_string()) + .unwrap_or_else(|| ".".to_string()); + + let language = extension + .map(LanguageFamily::from_extension) + .unwrap_or(LanguageFamily::Other); + + Self { + directory, + language, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // โ”€โ”€โ”€ BundlingConfig defaults โ”€โ”€โ”€ + + #[test] + fn bundling_config_default() { + let cfg = BundlingConfig::default(); + assert_eq!(cfg.max_chars_per_group, 60_000); + assert_eq!(cfg.max_files_per_group, 20); + assert_eq!(cfg.strategy, GroupingStrategy::Smart); + assert!(cfg.coalesce_by_directory); + assert!(cfg.coalesce_by_language); + } + + // โ”€โ”€โ”€ GroupingStrategy display โ”€โ”€โ”€ + + #[test] + fn grouping_strategy_display() { + assert_eq!(format!("{}", GroupingStrategy::Smart), "smart"); + assert_eq!(format!("{}", GroupingStrategy::Flat), "flat"); + } + + // โ”€โ”€โ”€ LanguageFamily classification โ”€โ”€โ”€ + + #[test] + fn language_family_rust() { + assert_eq!(LanguageFamily::from_extension("rs"), LanguageFamily::Rust); + assert_eq!( + LanguageFamily::from_extension("toml"), + LanguageFamily::Config + ); + } + + #[test] + fn language_family_javascript() { + assert_eq!( + LanguageFamily::from_extension("js"), + LanguageFamily::JavaScript + ); + assert_eq!( + LanguageFamily::from_extension("ts"), + LanguageFamily::JavaScript + ); + assert_eq!( + LanguageFamily::from_extension("tsx"), + LanguageFamily::JavaScript + ); + assert_eq!( + LanguageFamily::from_extension("jsx"), + LanguageFamily::JavaScript + ); + } + + #[test] + fn language_family_python() { + assert_eq!(LanguageFamily::from_extension("py"), LanguageFamily::Python); + } + + #[test] + fn language_family_other() { + assert_eq!(LanguageFamily::from_extension("xyz"), LanguageFamily::Other); + } + + #[test] + fn language_family_case_insensitive() { + assert_eq!(LanguageFamily::from_extension("RS"), LanguageFamily::Rust); + assert_eq!(LanguageFamily::from_extension("Py"), LanguageFamily::Python); + } + + // โ”€โ”€โ”€ GroupingKey โ”€โ”€โ”€ + + #[test] + fn grouping_key_from_file() { + let key = GroupingKey::from_file("src/engine/mod.rs", Some("rs")); + assert_eq!(key.directory, "src/engine"); + assert_eq!(key.language, LanguageFamily::Rust); + } + + #[test] + fn grouping_key_root_file() { + let key = GroupingKey::from_file("main.rs", Some("rs")); + assert_eq!(key.directory, "."); + assert_eq!(key.language, LanguageFamily::Rust); + } + + #[test] + fn grouping_key_no_extension() { + let key = GroupingKey::from_file("Makefile", None); + assert_eq!(key.directory, "."); + assert_eq!(key.language, LanguageFamily::Other); + } + + // โ”€โ”€โ”€ FileGroup โ”€โ”€โ”€ + + #[test] + fn file_group_new() { + let group = FileGroup::new(); + assert!(group.is_empty()); + assert_eq!(group.len(), 0); + assert_eq!(group.total_chars, 0); + } + + #[test] + fn file_group_push_and_fit() { + let mut group = FileGroup::new(); + let file = crate::engine::scanner::FileEntry { + path: "test.rs".to_string(), + content: "fn main() {}".to_string(), + lines: 1, + }; + + assert!(group.would_fit(&file, 1000, 10)); + group.push(file); + assert_eq!(group.len(), 1); + assert!(!group.is_empty()); + } + + #[test] + fn file_group_max_files_limit() { + let mut group = FileGroup::new(); + for i in 0..3 { + group.push(crate::engine::scanner::FileEntry { + path: format!("file_{i}.rs"), + content: "x".to_string(), + lines: 1, + }); + } + // 3 files already, max 3 + let new_file = crate::engine::scanner::FileEntry { + path: "file_4.rs".to_string(), + content: "x".to_string(), + lines: 1, + }; + assert!(!group.would_fit(&new_file, 1_000_000, 3)); + } + + #[test] + fn file_group_max_chars_limit() { + let mut group = FileGroup::new(); + group.push(crate::engine::scanner::FileEntry { + path: "big.rs".to_string(), + content: "x".repeat(500), + lines: 1, + }); + let new_file = crate::engine::scanner::FileEntry { + path: "small.rs".to_string(), + content: "x".repeat(500), + lines: 1, + }; + // First file always fits, second should not if we set tight limit + // Total would be ~500 + path + 20 + 500 + path + 20 โ‰ˆ 1060 + assert!(!group.would_fit(&new_file, 800, 100)); + } + + #[test] + fn file_group_first_file_always_fits() { + let group = FileGroup::new(); + let huge_file = crate::engine::scanner::FileEntry { + path: "huge.rs".to_string(), + content: "x".repeat(1_000_000), + lines: 1000, + }; + // Even with tiny char limit, first file should fit + assert!(group.would_fit(&huge_file, 100, 10)); + } + + #[test] + fn file_cost_calculation() { + let file = crate::engine::scanner::FileEntry { + path: "test.rs".to_string(), + content: "hello world".to_string(), + lines: 1, + }; + let cost = FileGroup::file_cost(&file); + // "hello world".len() = 11 + "test.rs".len() = 7 + 20 overhead = 38 + assert_eq!(cost, 38); + } + + // โ”€โ”€โ”€ GroupingKey equality โ”€โ”€โ”€ + + #[test] + fn grouping_key_equality() { + let k1 = GroupingKey::from_file("src/main.rs", Some("rs")); + let k2 = GroupingKey::from_file("src/lib.rs", Some("rs")); + let k3 = GroupingKey::from_file("src/main.rs", Some("py")); + + assert_eq!(k1, k2); // same directory + language + assert_ne!(k1, k3); // same directory, different language + } + + // โ”€โ”€โ”€ BundlingConfig serde round-trip โ”€โ”€โ”€ + + #[test] + fn bundling_config_serde_roundtrip() { + let cfg = BundlingConfig { + max_chars_per_group: 30_000, + max_files_per_group: 10, + strategy: GroupingStrategy::Flat, + coalesce_by_directory: false, + coalesce_by_language: false, + }; + let json = serde_json::to_string(&cfg).unwrap(); + let back: BundlingConfig = serde_json::from_str(&json).unwrap(); + assert_eq!(back.max_chars_per_group, 30_000); + assert_eq!(back.max_files_per_group, 10); + assert_eq!(back.strategy, GroupingStrategy::Flat); + assert!(!back.coalesce_by_directory); + assert!(!back.coalesce_by_language); + } +} diff --git a/src/engine/cache.rs b/src/engine/cache.rs new file mode 100644 index 00000000..8f0926e1 --- /dev/null +++ b/src/engine/cache.rs @@ -0,0 +1,272 @@ +use crate::error::CoraError; +use sha2::{Digest, Sha256}; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; +use tracing::debug; + +use crate::engine::types::ReviewResponse; + +/// Get the cache directory: ~/.cache/cora/reviews/ +fn cache_dir() -> std::result::Result { + let home = dirs::home_dir() + .ok_or_else(|| CoraError::ConfigRead("cannot determine home directory".into()))?; + Ok(home.join(".cache").join("cora").join("reviews")) +} + +/// Compute SHA-256 hex digest of the diff content + config parameters. +/// Includes model and temperature so config changes invalidate the cache. +#[allow(clippy::format_collect)] +fn cache_key(diff: &str, model: &str, temperature: f32) -> String { + let mut hasher = Sha256::new(); + hasher.update(diff.as_bytes()); + hasher.update(model.as_bytes()); + hasher.update(temperature.to_le_bytes()); + let result = hasher.finalize(); + result.iter().map(|b| format!("{b:02x}")).collect() +} + +/// Get cached review response if available and not expired. +/// +/// Returns `None` if no cache exists or if the cache entry has expired +/// (based on `ttl` in minutes). +pub fn get_cached_review( + diff: &str, + model: &str, + temperature: f32, + ttl: u64, +) -> Option { + let hash = cache_key(diff, model, temperature); + let dir = cache_dir().ok()?; + let path = dir.join(format!("{hash}.json")); + + if !path.is_file() { + debug!("cache miss: file not found"); + return None; + } + + let content = std::fs::read_to_string(&path).ok()?; + let cached: CachedReview = match serde_json::from_str(&content) { + Ok(c) => c, + Err(e) => { + debug!("cache corrupt, ignoring: {}", e); + return None; + } + }; + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let age_secs = now.saturating_sub(cached.timestamp); + let ttl_secs = ttl * 60; + + if age_secs > ttl_secs { + debug!(age_secs = age_secs, ttl_secs = ttl_secs, "cache expired"); + // Clean up expired cache file + let _ = std::fs::remove_file(&path); + return None; + } + + debug!(hash = %hash, age_secs = age_secs, "cache hit"); + Some(cached.response) +} + +/// Save a review response to the cache. +pub fn save_cached_review( + diff: &str, + model: &str, + temperature: f32, + response: &ReviewResponse, +) -> std::result::Result<(), CoraError> { + let dir = cache_dir()?; + std::fs::create_dir_all(&dir).map_err(CoraError::CacheIo)?; + + let hash = cache_key(diff, model, temperature); + let path = dir.join(format!("{hash}.json")); + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let cached = CachedReview { + response: response.clone(), + timestamp: now, + }; + + let json = serde_json::to_string_pretty(&cached) + .map_err(|e| CoraError::CacheSerialize(e.to_string()))?; + + std::fs::write(&path, json).map_err(CoraError::CacheIo)?; + + debug!(hash = %hash, "saved review to cache"); + Ok(()) +} + +/// Internal cache entry format. +#[derive(serde::Serialize, serde::Deserialize)] +struct CachedReview { + response: ReviewResponse, + timestamp: u64, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::engine::Severity; + use crate::engine::types::ReviewIssue; + + fn make_response() -> ReviewResponse { + ReviewResponse { + issues: vec![ReviewIssue { + file: "src/main.rs".to_string(), + line: Some(10), + severity: Severity::Major, + issue_type: Some("bugs".to_string()), + title: "Null pointer".to_string(), + body: "Could be null here".to_string(), + suggested_fix: Some("Add a check".to_string()), + }], + summary: "Found 1 issue.".to_string(), + tokens_used: None, + should_block: false, + } + } + + #[test] + fn cache_key_is_deterministic() { + let hash1 = cache_key("hello world", "gpt-4", 0.0); + let hash2 = cache_key("hello world", "gpt-4", 0.0); + assert_eq!(hash1, hash2); + assert_eq!(hash1.len(), 64); // SHA-256 hex = 64 chars + } + + #[test] + fn cache_key_differs_for_different_inputs() { + let hash1 = cache_key("hello world", "gpt-4", 0.0); + let hash2 = cache_key("hello earth", "gpt-4", 0.0); + assert_ne!(hash1, hash2); + } + + #[test] + fn cache_key_includes_model_and_temperature() { + let h1 = cache_key("diff", "gpt-4", 0.0); + let h2 = cache_key("diff", "gpt-3.5", 0.0); + let h3 = cache_key("diff", "gpt-4", 0.7); + assert_ne!(h1, h2, "different models should differ"); + assert_ne!(h1, h3, "different temperatures should differ"); + } + + #[test] + fn cache_key_len_is_64() { + let diff = "diff --git a/file.txt b/file.txt\n+ hello"; + let hash = cache_key(diff, "model", 0.0); + assert_eq!(hash.len(), 64); + } + + #[test] + fn cache_miss_on_different_diff() { + let diff1 = "diff --git a/a.txt b/a.txt\n+ hello"; + let diff2 = "diff --git a/b.txt b/b.txt\n+ world"; + let hash1 = cache_key(diff1, "model", 0.0); + let hash2 = cache_key(diff2, "model", 0.0); + assert_ne!(hash1, hash2); + } + + #[test] + fn cached_review_serialization_roundtrip() { + let response = make_response(); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let cached = CachedReview { + response, + timestamp: now, + }; + + let json = serde_json::to_string(&cached).unwrap(); + let back: CachedReview = serde_json::from_str(&json).unwrap(); + assert_eq!(back.timestamp, now); + assert_eq!(back.response.issues.len(), 1); + assert_eq!(back.response.issues[0].file, "src/main.rs"); + assert_eq!(back.response.summary, "Found 1 issue."); + } + + #[test] + fn ttl_expiry() { + let response = make_response(); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let cached = CachedReview { + response, + timestamp: now.saturating_sub(2 * 60 * 60), // 2 hours ago + }; + + let json = serde_json::to_string(&cached).unwrap(); + let back: CachedReview = serde_json::from_str(&json).unwrap(); + + // With ttl = 60 (1 hour), this should be expired + let age_secs = now - back.timestamp; + let ttl_secs = 60 * 60; + assert!(age_secs > ttl_secs); + } + + #[test] + fn ttl_not_expired() { + let response = make_response(); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let cached = CachedReview { + response, + timestamp: now.saturating_sub(10 * 60), // 10 minutes ago + }; + + let json = serde_json::to_string(&cached).unwrap(); + let back: CachedReview = serde_json::from_str(&json).unwrap(); + + // With ttl = 1440 (24h), this should NOT be expired + let age_secs = now - back.timestamp; + let ttl_secs = 1440 * 60; + assert!(age_secs <= ttl_secs); + } + + #[test] + fn save_and_get_cached_review() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path(); + + // Manually set up a cache entry in the temp dir + let diff = "test diff content"; + let hash = cache_key(diff, "model", 0.0); + let path = dir.join(format!("{hash}.json")); + + let response = make_response(); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let cached = CachedReview { + response: response.clone(), + timestamp: now, + }; + + let json = serde_json::to_string(&cached).unwrap(); + std::fs::write(&path, json).unwrap(); + + // Verify we can read it back + let content = std::fs::read_to_string(&path).unwrap(); + let back: CachedReview = serde_json::from_str(&content).unwrap(); + assert_eq!(back.response.issues.len(), 1); + assert_eq!(back.response.issues[0].title, "Null pointer"); + } +} diff --git a/src/engine/context/extraction.rs b/src/engine/context/extraction.rs new file mode 100644 index 00000000..53f0fb5b --- /dev/null +++ b/src/engine/context/extraction.rs @@ -0,0 +1,664 @@ +//! Symbol extraction from source code lines. +//! +//! Uses language-aware regex patterns to extract function calls, type +//! references, and import statements from changed diff lines. No full +//! AST parsing โ€” regex is sufficient for the deterministic context chain +//! and avoids pulling in heavy dependencies like `syn`. + +use std::collections::HashSet; + +use regex::Regex; +use std::sync::LazyLock; +use tracing::debug; + +use super::types::{ExtractedSymbol, SymbolKind}; + +// --- Regex patterns by language --- + +/// Rust: `use crate::foo::bar` or `use super::baz` or `mod foo` +static RE_RUST_IMPORT: LazyLock = + LazyLock::new(|| Regex::new(r"\buse\s+(crate|super|self)::([\w:]+)").unwrap()); + +/// Rust: function call โ€” identifier followed by `(`, possibly preceded by `::` or `.` +static RE_RUST_FN_CALL: LazyLock = + LazyLock::new(|| Regex::new(r"(?:\w+::)?\w+(?:::\w+)*\s*\(").unwrap()); + +/// Rust: type-like references โ€” `Type`, `Option`, `Vec`, after `: `, `<`, or `as` +static RE_RUST_TYPE: LazyLock = + LazyLock::new(|| Regex::new(r"(?:(?::\s)|(?:<)|(?:as\s)|(?:->\s))([A-Z]\w+)").unwrap()); + +/// Python: `import foo` or `from foo import bar` +static RE_PY_IMPORT: LazyLock = + LazyLock::new(|| Regex::new(r"\b(?:from|import)\s+([\w.]+)").unwrap()); + +/// Python: function call โ€” `foo(` or `obj.method(` +static RE_PY_FN_CALL: LazyLock = LazyLock::new(|| Regex::new(r"\b(\w+)\s*\(").unwrap()); + +/// Python: type annotation โ€” `: SomeType` or `-> ReturnType` +static RE_PY_TYPE: LazyLock = + LazyLock::new(|| Regex::new(r"(?::\s*[A-Z]\w*|->\s*[A-Z]\w*)").unwrap()); + +/// JS/TS: `import ... from '...'`, `require('...')`, or dynamic `import('...')` +static RE_JS_IMPORT: LazyLock = LazyLock::new(|| { + Regex::new(r#"(?:import\s+.*?\s+from\s*['"]([^'"]+)['"]|require\s*\(\s*['"]([^'"]+)['"]|import\s*\(\s*['"]([^'"]+)['"])"#).unwrap() +}); + +/// JS/TS: function call โ€” `foo(` or `obj.method(` +static RE_JS_FN_CALL: LazyLock = LazyLock::new(|| Regex::new(r"\b(\w+)\s*\(").unwrap()); + +/// Go: `import "foo"` or `import foo "bar"` +static RE_GO_IMPORT: LazyLock = + LazyLock::new(|| Regex::new(r#"\bimport\s+[\w.]*\s*["']([^"']+)["']"#).unwrap()); + +/// Go: function call โ€” `foo(` or `pkg.Func(` +static RE_GO_FN_CALL: LazyLock = + LazyLock::new(|| Regex::new(r"\b([A-Z]\w*\.\w+|\w+)\s*\(").unwrap()); + +/// Go: type reference โ€” `SomeType{` or `var x SomeType` or `: SomeType` +static RE_GO_TYPE: LazyLock = + LazyLock::new(|| Regex::new(r"(?:\b([A-Z]\w+)\s*\{|:\s*([A-Z]\w+))").unwrap()); + +/// Java: `import foo.bar.*` +static RE_JAVA_IMPORT: LazyLock = + LazyLock::new(|| Regex::new(r"\bimport\s+([\w.]+)").unwrap()); + +/// Java: method call โ€” `foo(` or `obj.method(` +static RE_JAVA_FN_CALL: LazyLock = LazyLock::new(|| Regex::new(r"\b(\w+)\s*\(").unwrap()); + +/// Java: type reference โ€” `SomeType ` or `` +static RE_JAVA_TYPE: LazyLock = + LazyLock::new(|| Regex::new(r"\b([A-Z]\w+)\s*[<{( ]").unwrap()); + +/// Maximum number of symbols to extract per file to prevent runaway extraction. +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 { + let mut symbols = Vec::new(); + let mut seen = HashSet::new(); + + let add_unique = |syms: &mut Vec, seen: &mut HashSet, s: SymbolKind| { + let key = format!("{s}"); + if seen.insert(key) { + syms.push(s); + } + }; + + match language { + "rs" => { + // Imports (highest value for resolution) + for cap in RE_RUST_IMPORT.captures_iter(line) { + add_unique( + &mut symbols, + &mut seen, + SymbolKind::Import(cap.get(2).unwrap().as_str().to_string()), + ); + } + // Function calls + for cap in RE_RUST_FN_CALL.captures_iter(line) { + let raw = cap.get(0).unwrap().as_str().trim_end_matches('(').trim(); + // Skip macros like `println!`, `vec!`, `format!`, `debug!`, etc. + if raw.ends_with('!') { + continue; + } + // Skip common keywords that look like function calls + if matches!( + raw, + "if" | "while" | "for" | "loop" | "match" | "return" | "break" + ) { + continue; + } + add_unique( + &mut symbols, + &mut seen, + SymbolKind::FunctionCall(raw.to_string()), + ); + } + // Type references + for cap in RE_RUST_TYPE.captures_iter(line) { + if let Some(type_name) = cap.get(1) { + let name = type_name.as_str().to_string(); + add_unique(&mut symbols, &mut seen, SymbolKind::TypeRef(name)); + } + } + } + "py" | "pyi" => { + for cap in RE_PY_IMPORT.captures_iter(line) { + add_unique( + &mut symbols, + &mut seen, + SymbolKind::Import(cap.get(1).unwrap().as_str().to_string()), + ); + } + for cap in RE_PY_FN_CALL.captures_iter(line) { + let name = cap.get(1).unwrap().as_str().to_string(); + if matches!( + name.as_str(), + "if" | "while" | "for" | "print" | "range" | "len" + ) { + continue; + } + add_unique(&mut symbols, &mut seen, SymbolKind::FunctionCall(name)); + } + for cap in RE_PY_TYPE.captures_iter(line) { + let full = cap.get(0).unwrap().as_str(); + // Extract the type name after `:` or `->` + let name = full.trim_start_matches(':').trim_start_matches('-').trim(); + if !name.is_empty() && name.starts_with(char::is_uppercase) { + add_unique( + &mut symbols, + &mut seen, + SymbolKind::TypeRef(name.to_string()), + ); + } + } + } + "ts" | "tsx" | "js" | "jsx" | "mjs" | "cjs" => { + for cap in RE_JS_IMPORT.captures_iter(line) { + // The regex has 3 capture groups (one per alternative). Take the first non-None. + let path = cap + .get(1) + .or_else(|| cap.get(2)) + .or_else(|| cap.get(3)) + .unwrap() + .as_str() + .to_string(); + add_unique(&mut symbols, &mut seen, SymbolKind::Import(path)); + } + for cap in RE_JS_FN_CALL.captures_iter(line) { + let name = cap.get(1).unwrap().as_str().to_string(); + if matches!(name.as_str(), "if" | "while" | "for" | "require" | "import") { + continue; + } + add_unique(&mut symbols, &mut seen, SymbolKind::FunctionCall(name)); + } + } + "go" => { + for cap in RE_GO_IMPORT.captures_iter(line) { + add_unique( + &mut symbols, + &mut seen, + SymbolKind::Import(cap.get(1).unwrap().as_str().to_string()), + ); + } + for cap in RE_GO_FN_CALL.captures_iter(line) { + let name = cap.get(1).unwrap().as_str().to_string(); + if matches!(name.as_str(), "if" | "for" | "range" | "defer" | "go") { + continue; + } + add_unique(&mut symbols, &mut seen, SymbolKind::FunctionCall(name)); + } + for cap in RE_GO_TYPE.captures_iter(line) { + if let Some(type_name) = cap.get(1).or_else(|| cap.get(2)) { + add_unique( + &mut symbols, + &mut seen, + SymbolKind::TypeRef(type_name.as_str().to_string()), + ); + } + } + } + "java" | "kt" | "kts" => { + for cap in RE_JAVA_IMPORT.captures_iter(line) { + add_unique( + &mut symbols, + &mut seen, + SymbolKind::Import(cap.get(1).unwrap().as_str().to_string()), + ); + } + for cap in RE_JAVA_FN_CALL.captures_iter(line) { + let name = cap.get(1).unwrap().as_str().to_string(); + if matches!( + name.as_str(), + "if" | "while" | "for" | "switch" | "return" | "new" | "instanceof" + ) { + continue; + } + add_unique(&mut symbols, &mut seen, SymbolKind::FunctionCall(name)); + } + for cap in RE_JAVA_TYPE.captures_iter(line) { + if let Some(type_name) = cap.get(1) { + let name = type_name.as_str().to_string(); + // Skip common non-user types + if matches!( + name.as_str(), + "String" | "Integer" | "Boolean" | "List" | "Map" + ) { + continue; + } + add_unique(&mut symbols, &mut seen, SymbolKind::TypeRef(name)); + } + } + } + _ => { + // Fallback: basic function call detection for any language + for cap in RE_PY_FN_CALL.captures_iter(line) { + let name = cap.get(1).unwrap().as_str().to_string(); + if name.len() > 2 { + add_unique(&mut symbols, &mut seen, SymbolKind::FunctionCall(name)); + } + } + } + } + + symbols +} + +/// Extract all symbols from the added lines of parsed diff chunks. +/// +/// Returns a list of `ExtractedSymbol` entries, one per symbol found. +/// Duplicates across lines are deduplicated (same kind + same name + same file). +pub fn extract_symbols_from_diff( + chunks: &[crate::engine::diff_parser::FileChunk], +) -> Vec { + let mut all_symbols = Vec::new(); + let mut seen: HashSet<(String, String)> = HashSet::new(); // (symbol key, file) + + for file in chunks { + if file.is_binary || file.is_deleted { + continue; + } + + let file_path = file + .new_path + .as_deref() + .or(file.old_path.as_deref()) + .unwrap_or("unknown"); + + let mut file_symbol_count = 0; + + for hunk in &file.chunks { + for line in &hunk.lines { + // Only extract from added lines + if line.line_type != crate::engine::diff_parser::DiffLineType::Add { + continue; + } + + let language = &file.language; + let symbols = extract_symbols_from_line(&line.content, language); + + for sym in symbols { + if file_symbol_count >= MAX_SYMBOLS_PER_FILE { + break; + } + + let key = format!("{sym}"); + if seen.insert((key, file_path.to_string())) { + all_symbols.push(ExtractedSymbol { + file: file_path.to_string(), + line: line.new_line_no.unwrap_or(0), + kind: sym, + raw: line.content.clone(), + }); + file_symbol_count += 1; + } + } + } + } + + debug!( + file = file_path, + symbols = file_symbol_count, + "extracted symbols from file" + ); + } + + debug!( + total = all_symbols.len(), + "total symbols extracted from diff" + ); + all_symbols +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::engine::diff_parser::{DiffHunk, DiffLine, DiffLineType, FileChunk, parse_diff}; + + fn make_file_chunk(path: &str, lang: &str, added_lines: &[(&str, u32)]) -> FileChunk { + FileChunk { + old_path: Some(path.to_string()), + new_path: Some(path.to_string()), + language: lang.to_string(), + chunks: vec![DiffHunk { + old_start: 1, + old_count: 0, + new_start: 1, + new_count: added_lines.len() as u32, + header: String::new(), + lines: added_lines + .iter() + .map(|(text, line_no)| DiffLine { + line_type: DiffLineType::Add, + content: text.to_string(), + old_line_no: None, + new_line_no: Some(*line_no), + }) + .collect(), + }], + is_binary: false, + is_deleted: false, + is_new: false, + } + } + + // --- Rust extraction --- + + #[test] + fn extract_rust_function_calls() { + let chunks = vec![make_file_chunk( + "src/main.rs", + "rs", + &[("let result = validate_token(tok);", 5)], + )]; + let symbols = extract_symbols_from_diff(&chunks); + let fn_calls: Vec<_> = symbols + .iter() + .filter( + |s| matches!(&s.kind, SymbolKind::FunctionCall(name) if name == "validate_token"), + ) + .collect(); + assert!( + !fn_calls.is_empty(), + "should extract validate_token function call from Rust" + ); + } + + #[test] + fn extract_rust_imports() { + let chunks = vec![make_file_chunk( + "src/engine/mod.rs", + "rs", + &[("use crate::engine::scanner;", 1)], + )]; + let symbols = extract_symbols_from_diff(&chunks); + let imports: Vec<_> = symbols + .iter() + .filter(|s| matches!(&s.kind, SymbolKind::Import(p) if p == "engine::scanner")) + .collect(); + assert!( + !imports.is_empty(), + "should extract use crate::engine::scanner" + ); + } + + #[test] + fn extract_rust_type_refs() { + let chunks = vec![make_file_chunk( + "src/config.rs", + "rs", + &[("let config: CryptoConfig = load();", 10)], + )]; + let symbols = extract_symbols_from_diff(&chunks); + let types: Vec<_> = symbols + .iter() + .filter(|s| matches!(&s.kind, SymbolKind::TypeRef(t) if t == "CryptoConfig")) + .collect(); + assert!( + !types.is_empty(), + "should extract CryptoConfig type reference" + ); + } + + #[test] + fn rust_skips_macros() { + let chunks = vec![make_file_chunk( + "src/main.rs", + "rs", + &[ + ("println!(\"hello\");", 1), + ("debug!(\"value: {}\", x);", 2), + ], + )]; + let symbols = extract_symbols_from_diff(&chunks); + let fn_calls: Vec<_> = symbols + .iter() + .filter(|s| matches!(&s.kind, SymbolKind::FunctionCall(_))) + .collect(); + assert!( + fn_calls.is_empty(), + "should skip Rust macros (println!, debug!, etc.)" + ); + } + + #[test] + fn rust_skips_keywords() { + let chunks = vec![make_file_chunk( + "src/main.rs", + "rs", + &[("if condition { return; }", 1)], + )]; + let symbols = extract_symbols_from_diff(&chunks); + let fn_calls: Vec<_> = symbols + .iter() + .filter(|s| matches!(&s.kind, SymbolKind::FunctionCall(name) if name == "if" || name == "return")) + .collect(); + assert!(fn_calls.is_empty(), "should skip Rust keyword-like calls"); + } + + // --- Python extraction --- + + #[test] + fn extract_python_imports() { + let chunks = vec![make_file_chunk( + "app/auth.py", + "py", + &[("from auth import validate_token", 1)], + )]; + let symbols = extract_symbols_from_diff(&chunks); + let imports: Vec<_> = symbols + .iter() + .filter(|s| matches!(&s.kind, SymbolKind::Import(p) if p == "auth")) + .collect(); + assert!(!imports.is_empty(), "should extract Python from/import"); + } + + #[test] + fn extract_python_function_calls() { + let chunks = vec![make_file_chunk( + "app/auth.py", + "py", + &[("result = validate_token(tok)", 5)], + )]; + let symbols = extract_symbols_from_diff(&chunks); + let fn_calls: Vec<_> = symbols + .iter() + .filter( + |s| matches!(&s.kind, SymbolKind::FunctionCall(name) if name == "validate_token"), + ) + .collect(); + assert!( + !fn_calls.is_empty(), + "should extract validate_token from Python" + ); + } + + // --- JavaScript/TypeScript extraction --- + + #[test] + fn extract_js_imports() { + let chunks = vec![make_file_chunk( + "src/api.ts", + "ts", + &[("import { validate } from './auth';", 1)], + )]; + let symbols = extract_symbols_from_diff(&chunks); + let imports: Vec<_> = symbols + .iter() + .filter(|s| matches!(&s.kind, SymbolKind::Import(p) if p == "./auth")) + .collect(); + assert!(!imports.is_empty(), "should extract JS/TS import path"); + } + + // --- Go extraction --- + + #[test] + fn extract_go_imports() { + let chunks = vec![make_file_chunk( + "main.go", + "go", + &[("import \"net/http\"", 3)], + )]; + let symbols = extract_symbols_from_diff(&chunks); + let imports: Vec<_> = symbols + .iter() + .filter(|s| matches!(&s.kind, SymbolKind::Import(p) if p == "net/http")) + .collect(); + assert!(!imports.is_empty(), "should extract Go import"); + } + + // --- Edge cases --- + + #[test] + fn skips_binary_files() { + let chunks = vec![FileChunk { + old_path: Some("image.png".to_string()), + new_path: Some("image.png".to_string()), + language: "image".to_string(), + chunks: vec![], + is_binary: true, + is_deleted: false, + is_new: false, + }]; + let symbols = extract_symbols_from_diff(&chunks); + assert!(symbols.is_empty(), "should skip binary files"); + } + + #[test] + fn skips_deleted_files() { + let chunks = vec![FileChunk { + old_path: Some("old.rs".to_string()), + new_path: None, + language: "rs".to_string(), + chunks: vec![], + is_binary: false, + is_deleted: true, + is_new: false, + }]; + let symbols = extract_symbols_from_diff(&chunks); + assert!(symbols.is_empty(), "should skip deleted files"); + } + + #[test] + fn skips_context_and_removed_lines() { + let chunks = vec![FileChunk { + old_path: Some("src/lib.rs".to_string()), + new_path: Some("src/lib.rs".to_string()), + language: "rs".to_string(), + chunks: vec![DiffHunk { + old_start: 1, + old_count: 2, + new_start: 1, + new_count: 1, + header: String::new(), + lines: vec![ + DiffLine { + line_type: DiffLineType::Context, + content: "fn old() {}".to_string(), + old_line_no: Some(1), + new_line_no: Some(1), + }, + DiffLine { + line_type: DiffLineType::Remove, + content: "fn removed() {}".to_string(), + old_line_no: Some(2), + new_line_no: None, + }, + ], + }], + is_binary: false, + is_deleted: false, + is_new: false, + }]; + let symbols = extract_symbols_from_diff(&chunks); + assert!(symbols.is_empty(), "should only extract from added lines"); + } + + #[test] + fn deduplicates_across_lines() { + let chunks = vec![make_file_chunk( + "src/main.rs", + "rs", + &[ + ("let a = foo();", 1), + ("let b = foo();", 2), + ("let c = bar();", 3), + ], + )]; + let symbols = extract_symbols_from_diff(&chunks); + let foo_count = symbols + .iter() + .filter(|s| matches!(&s.kind, SymbolKind::FunctionCall(n) if n == "foo")) + .count(); + assert_eq!( + foo_count, 1, + "should deduplicate same symbol across lines in same file" + ); + } + + #[test] + fn respects_max_symbols_per_file() { + let lines: Vec<(&str, u32)> = (0..60u32) + .map(|i| { + let s = format!("let x{i} = func{i}();"); + // Leak to get &'static str + let leaked: &'static str = Box::leak(s.into_boxed_str()); + (leaked, i + 1) + }) + .collect(); + let chunks = vec![make_file_chunk("src/big.rs", "rs", &lines)]; + let symbols = extract_symbols_from_diff(&chunks); + assert!( + symbols.len() <= MAX_SYMBOLS_PER_FILE, + "should cap at MAX_SYMBOLS_PER_FILE" + ); + } + + #[test] + fn unknown_language_fallback() { + let chunks = vec![make_file_chunk( + "README.md", + "md", + &[("see foo() for details", 1)], + )]; + let symbols = extract_symbols_from_diff(&chunks); + // Fallback should at least extract function calls + let fn_calls: Vec<_> = symbols + .iter() + .filter(|s| matches!(&s.kind, SymbolKind::FunctionCall(n) if n == "foo")) + .collect(); + assert!( + !fn_calls.is_empty(), + "fallback should extract function calls for unknown languages" + ); + } + + #[test] + fn full_diff_extraction() { + let diff = r#"diff --git a/src/auth.rs b/src/auth.rs +--- a/src/auth.rs ++++ b/src/auth.rs +@@ -10,6 +10,8 @@ + use crate::config::CryptoConfig; ++use crate::engine::scanner; + + pub fn authenticate(req: &Request) -> Result { ++ let config = CryptoConfig::load(); ++ let valid = validate_token(&req.token, &config); ++ scanner::scan_file(&req.path) + } +"#; + let chunks = parse_diff(diff); + let symbols = extract_symbols_from_diff(&chunks); + // Should find imports, function calls, and type refs + assert!(!symbols.is_empty(), "should extract symbols from full diff"); + let has_import = symbols + .iter() + .any(|s| matches!(&s.kind, SymbolKind::Import(_))); + let has_fn_call = symbols + .iter() + .any(|s| matches!(&s.kind, SymbolKind::FunctionCall(_))); + assert!(has_import, "should find import symbols"); + assert!(has_fn_call, "should find function call symbols"); + } +} diff --git a/src/engine/context/mod.rs b/src/engine/context/mod.rs new file mode 100644 index 00000000..ff872829 --- /dev/null +++ b/src/engine/context/mod.rs @@ -0,0 +1,106 @@ +//! Context chain โ€” deterministic cross-file dependency extraction. +//! +//! Enriches LLM review prompts with function definitions, type definitions, +//! and test coverage information extracted from the changed code, without +//! using any LLM tokens. +//! +//! ## Architecture +//! +//! 1. **Extraction** (`extraction.rs`): Extract function calls, type refs, +//! and imports from changed diff lines using language-aware regex. +//! +//! 2. **Resolution** (`resolver.rs`): Resolve extracted symbols to concrete +//! file locations (e.g., `use crate::auth::validate` โ†’ `src/auth/validate.rs`). +//! +//! 3. **Assembly**: Read file content under a token budget, assemble into a +//! formatted string for prompt injection. +//! +//! ## Usage +//! +//! ```rust,ignore +//! let chain = context::build_context_chain( +//! &extracted_symbols, +//! &config.context_chain, +//! &project_root, +//! &config.ignore.files, +//! ); +//! if !chain.text.is_empty() { +//! // Inject chain.text into the review prompt +//! } +//! ``` + +pub mod extraction; +pub mod resolver; +pub mod types; + +use std::path::Path; + +use crate::engine::diff_parser::FileChunk; +use types::{ContextChain, ContextConfig}; + +/// Build the full context chain from parsed diff chunks. +/// +/// Convenience entry point that combines extraction and resolution. +/// Returns the assembled context chain ready for prompt injection. +pub fn build_context_chain( + chunks: &[FileChunk], + config: &ContextConfig, + project_root: &Path, + ignore_patterns: &[String], +) -> ContextChain { + // Step 1: Extract symbols from changed lines + let symbols = extraction::extract_symbols_from_diff(chunks); + + if symbols.is_empty() || !config.enabled { + return ContextChain::default(); + } + + // Step 2: Resolve and assemble under budget + resolver::build_context_chain(&symbols, config, project_root, ignore_patterns) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn default_config() -> ContextConfig { + ContextConfig::default() + } + + #[test] + fn empty_chunks_returns_empty() { + let chain = build_context_chain(&[], &default_config(), Path::new("/tmp"), &[]); + assert!(chain.text.is_empty()); + } + + #[test] + fn disabled_returns_empty() { + let config = ContextConfig { + enabled: false, + ..default_config() + }; + let chunks = crate::engine::diff_parser::parse_diff( + "diff --git a/src/main.rs b/src/main.rs\n--- a/src/main.rs\n+++ b/src/main.rs\n@@ -1,1 +1,2 @@\n fn main() {\n+ foo();\n }\n", + ); + let chain = build_context_chain(&chunks, &config, Path::new("/tmp"), &[]); + assert!(chain.text.is_empty()); + } + + #[test] + fn extracts_from_real_diff() { + let diff = r#"diff --git a/src/main.rs b/src/main.rs +--- a/src/main.rs ++++ b/src/main.rs +@@ -5,6 +5,8 @@ + fn main() { + println!("hello"); ++ let config = load_config(); ++ authenticate(&config); + } +"#; + let chunks = crate::engine::diff_parser::parse_diff(diff); + let chain = build_context_chain(&chunks, &default_config(), Path::new("/nonexistent"), &[]); + // Should extract symbols but not resolve (no files exist) + assert_eq!(chain.stats.symbols_extracted, 2); + } +} diff --git a/src/engine/context/resolver.rs b/src/engine/context/resolver.rs new file mode 100644 index 00000000..46521a3e --- /dev/null +++ b/src/engine/context/resolver.rs @@ -0,0 +1,921 @@ +//! Symbol resolution โ€” map extracted symbols to file locations, +//! read source content, and assemble the context chain under a token budget. +//! +//! Resolution strategy (language-aware): +//! - **Rust**: `use crate::foo::bar` โ†’ `src/foo/bar.rs` (or `mod.rs`) +//! - **Python**: `import foo.bar` โ†’ `foo/bar.py` or `foo/bar/__init__.py` +//! - **JS/TS**: `import x from './foo'` โ†’ relative path resolution +//! - **Go**: `import "pkg/path"` โ†’ `pkg/path/` directory +//! - **Java/Kotlin**: `import foo.Bar` โ†’ `foo/Bar.java` / `foo/Bar.kt` +//! +//! Additionally, test file mapping is supported via naming conventions. + +use std::collections::HashMap; +use std::path::Path; + +use tracing::debug; + +use super::types::{ + ContextChain, ContextConfig, ContextEntry, ContextPriority, ContextStats, ExtractedSymbol, + SymbolKind, estimate_tokens, +}; + +/// Maximum lines to read per symbol definition (prevents reading huge functions). +const MAX_FN_LINES: usize = 50; +/// Maximum lines to read per struct/type definition. +const MAX_TYPE_LINES: usize = 50; +/// Maximum lines to read per test function. +const MAX_TEST_LINES: usize = 30; + +/// Build the full context chain from extracted symbols. +/// +/// This is the main entry point: extract โ†’ resolve โ†’ read โ†’ budget โ†’ assemble. +pub fn build_context_chain( + symbols: &[ExtractedSymbol], + config: &ContextConfig, + project_root: &Path, + ignore_patterns: &[String], +) -> ContextChain { + if !config.enabled || symbols.is_empty() { + return ContextChain::default(); + } + + let mut stats = ContextStats { + symbols_extracted: symbols.len(), + ..Default::default() + }; + + // Phase 1: Resolve symbols to file locations + let mut entries = resolve_symbols(symbols, config, project_root, ignore_patterns, &mut stats); + + // Phase 2: Add test file mappings + if config.include_tests { + add_test_mappings(symbols, project_root, &mut entries, &mut stats); + } + + // Sort by priority (FunctionDef first, Test last) + entries.sort_by_key(|e| e.priority); + + // Phase 3: Read file content under budget + let mut budget = config.max_context_tokens; + let mut parts = Vec::new(); + + for entry in &entries { + let content = read_entry_content(entry, project_root); + if content.is_empty() { + continue; + } + + let tokens = estimate_tokens(&content); + if tokens > budget { + stats.budget_hit = true; + debug!( + entry = %entry.label, + tokens, + remaining_budget = budget, + "skipping context entry (budget exhausted)" + ); + continue; + } + + budget -= tokens; + stats.entries_read += 1; + stats.estimated_tokens += tokens; + + parts.push(format!( + "--- {}:{}-{} ({}) ---\n{}", + entry.file, entry.line_start, entry.line_end, entry.label, content + )); + } + + let text = if parts.is_empty() { + String::new() + } else { + format!("Relevant Cross-File Context:\n\n{}", parts.join("\n")) + }; + + debug!( + symbols_extracted = stats.symbols_extracted, + symbols_resolved = stats.symbols_resolved, + entries_read = stats.entries_read, + estimated_tokens = stats.estimated_tokens, + budget_hit = stats.budget_hit, + "context chain built" + ); + + ContextChain { text, stats } +} + +/// Resolve extracted symbols to concrete file locations and line ranges. +fn resolve_symbols( + symbols: &[ExtractedSymbol], + config: &ContextConfig, + project_root: &Path, + ignore_patterns: &[String], + stats: &mut ContextStats, +) -> Vec { + let mut entries = Vec::new(); + let mut seen_files: HashMap> = HashMap::new(); // track line ranges per file + + for sym in symbols { + // Determine the language from the file extension + let lang = Path::new(&sym.file) + .extension() + .and_then(|e| e.to_str()) + .unwrap_or(""); + + let resolved = match &sym.kind { + SymbolKind::Import(path) => resolve_import(path, &sym.file, lang, project_root), + SymbolKind::FunctionCall(name) => resolve_function(name, &sym.file, lang, project_root), + SymbolKind::TypeRef(name) => resolve_type(name, &sym.file, lang, project_root), + }; + + for entry in resolved { + // Check ignore patterns + if is_ignored(&entry.file, ignore_patterns) { + debug!(file = %entry.file, "skipping ignored file"); + continue; + } + + // Check if the entry's file exists + let full_path = project_root.join(&entry.file); + if !full_path.exists() { + debug!(file = %entry.file, "resolved file does not exist, skipping"); + continue; + } + + // Don't add context for the same file the symbol came from + if entry.file == sym.file { + continue; + } + + stats.symbols_resolved += 1; + + // Merge line ranges for same file to avoid duplicates + let file_ranges = seen_files.entry(entry.file.clone()).or_default(); + let overlaps = file_ranges + .iter() + .any(|(s, e)| entry.line_start <= *e && entry.line_end >= *s); + + if !overlaps { + file_ranges.push((entry.line_start, entry.line_end)); + entries.push(entry); + } + } + + // Respect follow depth (depth 1 = only direct references) + if config.follow_depth <= 1 { + continue; + } + // Higher depths would recursively resolve symbols found in resolved content. + // For now, depth > 1 is a no-op placeholder for future expansion. + } + + entries +} + +/// Resolve an import to a file path. +fn resolve_import( + import_path: &str, + _source_file: &str, + lang: &str, + project_root: &Path, +) -> Vec { + let mut entries = Vec::new(); + + match lang { + "rs" => { + // `use crate::foo::bar::baz` โ†’ try `src/foo/bar/baz.rs` or `src/foo/bar/baz/mod.rs` + let path = import_path.replace("::", "/"); + let candidates = [ + format!("src/{path}.rs"), + format!("src/{path}/mod.rs"), + format!("src/{path}/lib.rs"), + ]; + + for candidate in &candidates { + let full = project_root.join(candidate); + if full.exists() { + let line_end = find_definition_end(&full); + entries.push(ContextEntry { + file: candidate.clone(), + line_start: 1, + line_end, + label: format!("module {import_path}"), + priority: ContextPriority::TypeDef, + }); + break; + } + } + } + "py" | "pyi" => { + // `import foo.bar` โ†’ `foo/bar.py` or `foo/bar/__init__.py` + let candidates = [ + format!("{import_path}.py"), + format!("{import_path}/__init__.py"), + ]; + + for candidate in &candidates { + let full = project_root.join(candidate); + if full.exists() { + let line_end = find_definition_end(&full); + entries.push(ContextEntry { + file: candidate.clone(), + line_start: 1, + line_end, + label: format!("module {import_path}"), + priority: ContextPriority::TypeDef, + }); + break; + } + } + } + "ts" | "tsx" | "js" | "jsx" | "mjs" | "cjs" => { + // `import x from './foo'` or `import x from 'foo'` โ†’ relative or node_modules + let path = import_path + .trim_start_matches("./") + .trim_start_matches("../"); + + // Only resolve relative imports (not node_modules) + if import_path.starts_with('.') { + let source_dir = Path::new(_source_file).parent().unwrap_or(Path::new("")); + let resolved = source_dir.join(path); + + let extensions = match lang { + "ts" | "tsx" => vec!["ts", "tsx"], + _ => vec!["js", "jsx", "mjs", "cjs"], + }; + + for ext in &extensions { + let candidate = format!("{}.{}", resolved.display(), ext); + let full = project_root.join(&candidate); + if full.exists() { + let line_end = find_definition_end(&full); + entries.push(ContextEntry { + file: candidate, + line_start: 1, + line_end, + label: format!("module {import_path}"), + priority: ContextPriority::TypeDef, + }); + break; + } + } + } + } + "go" => { + // Go imports are package paths; resolve relative to project root + let candidate = import_path.trim_start_matches("\"").trim_end_matches("\""); + let full = project_root.join(candidate); + if full.is_dir() { + // Find .go files in the directory + if let Some(entry) = find_go_package_file(&full, import_path) { + entries.push(entry); + } + } + } + "java" | "kt" | "kts" => { + // `import foo.bar.Baz` โ†’ `foo/bar/Baz.java` or `foo/bar/Baz.kt` + let path = import_path.replace('.', "/"); + let ext = if lang == "java" { "java" } else { "kt" }; + let candidate = format!("{path}.{ext}"); + let full = project_root.join(&candidate); + if full.exists() { + let line_end = find_definition_end(&full); + entries.push(ContextEntry { + file: candidate, + line_start: 1, + line_end, + label: format!("module {import_path}"), + priority: ContextPriority::TypeDef, + }); + } + } + _ => {} + } + + entries +} + +/// Resolve a function name to a file location. +/// This does a best-effort search for the function definition. +fn resolve_function( + name: &str, + source_file: &str, + lang: &str, + project_root: &Path, +) -> Vec { + let mut entries = Vec::new(); + + // Strategy: look in nearby files (same directory) and import targets + let search_dir = Path::new(source_file).parent().unwrap_or(Path::new("")); + + match lang { + "rs" => { + // Search for `pub fn ` or `fn ` in sibling .rs files + if let Ok(files) = std::fs::read_dir(project_root.join(search_dir)) { + for file in files.flatten() { + let path = file.path(); + if let Some(ext) = path.extension() { + if ext != "rs" { + continue; + } + } else { + continue; + } + + if let Ok(rel) = path.strip_prefix(project_root) { + let rel_str = rel.to_string_lossy().to_string(); + if rel_str == source_file { + continue; + } + + if let Some((start, end)) = find_fn_in_file(&path, name, "rs") { + entries.push(ContextEntry { + file: rel_str, + line_start: start, + line_end: end, + label: format!("fn {name}"), + priority: ContextPriority::FunctionDef, + }); + } + } + } + } + } + "py" | "pyi" => { + if let Ok(files) = std::fs::read_dir(project_root.join(search_dir)) { + for file in files.flatten() { + let path = file.path(); + if path.extension().map(|e| e != "py").unwrap_or(true) { + continue; + } + + if let Ok(rel) = path.strip_prefix(project_root) { + let rel_str = rel.to_string_lossy().to_string(); + if rel_str == source_file { + continue; + } + + if let Some((start, end)) = find_fn_in_file(&path, name, "py") { + entries.push(ContextEntry { + file: rel_str, + line_start: start, + line_end: end, + label: format!("def {name}"), + priority: ContextPriority::FunctionDef, + }); + } + } + } + } + } + _ => { + // Generic: search for the function name in sibling files + if let Ok(files) = std::fs::read_dir(project_root.join(search_dir)) { + for file in files.flatten() { + let path = file.path(); + if let Ok(rel) = path.strip_prefix(project_root) { + let rel_str = rel.to_string_lossy().to_string(); + if rel_str == source_file { + continue; + } + + if let Some((start, end)) = find_fn_generic(&path, name) { + entries.push(ContextEntry { + file: rel_str, + line_start: start, + line_end: end, + label: format!("fn {name}"), + priority: ContextPriority::FunctionDef, + }); + } + } + } + } + } + } + + entries +} + +/// Resolve a type name to a file location. +fn resolve_type( + name: &str, + source_file: &str, + lang: &str, + project_root: &Path, +) -> Vec { + let search_dir = Path::new(source_file).parent().unwrap_or(Path::new("")); + let mut entries = Vec::new(); + + let pattern = match lang { + "rs" => format!("struct {name}"), + "py" => format!("class {name}"), + "go" => format!("type {name} struct"), + "java" | "kt" => format!("class {name}"), + _ => format!("struct {name}"), + }; + + if let Ok(files) = std::fs::read_dir(project_root.join(search_dir)) { + for file in files.flatten() { + let path = file.path(); + if let Ok(rel) = path.strip_prefix(project_root) { + let rel_str = rel.to_string_lossy().to_string(); + if rel_str == source_file { + continue; + } + + if let Some((start, end)) = find_pattern_in_file(&path, &pattern) { + entries.push(ContextEntry { + file: rel_str, + line_start: start, + line_end: end, + label: format!("type {name}"), + priority: ContextPriority::TypeDef, + }); + } + } + } + } + + entries +} + +/// Find a function definition in a file and return (start_line, end_line). +fn find_fn_in_file(path: &Path, name: &str, lang: &str) -> Option<(u32, u32)> { + let content = std::fs::read_to_string(path).ok()?; + + let pattern = match lang { + "rs" => format!("fn {name}"), + "py" => format!("def {name}"), + _ => return find_fn_generic(path, name), + }; + + let max_lines = match lang { + "rs" => MAX_FN_LINES, + "py" => MAX_FN_LINES, + _ => MAX_FN_LINES, + }; + + find_pattern_with_body(&content, &pattern, max_lines) +} + +/// Generic function search (for languages without specific patterns). +fn find_fn_generic(path: &Path, name: &str) -> Option<(u32, u32)> { + let content = std::fs::read_to_string(path).ok()?; + find_pattern_with_body(&content, &format!("fn {name}"), MAX_FN_LINES) +} + +/// Find a pattern (like `struct Foo`) and determine its extent. +fn find_pattern_in_file(path: &Path, pattern: &str) -> Option<(u32, u32)> { + let content = std::fs::read_to_string(path).ok()?; + find_pattern_with_body(&content, pattern, MAX_TYPE_LINES) +} + +/// Find a pattern in content and estimate the block extent by counting braces/indents. +fn find_pattern_with_body(content: &str, pattern: &str, max_lines: usize) -> Option<(u32, u32)> { + let mut start_line: Option = None; + let mut brace_count = 0i32; + let mut line_idx = 0; + + for line in content.lines() { + line_idx += 1; + + if start_line.is_none() { + if line.contains(pattern) + && !line.trim_start().starts_with("//") + && !line.trim_start().starts_with('#') + { + start_line = Some(line_idx); + brace_count = count_braces_delta(line); + } + continue; + } + + brace_count += count_braces_delta(line); + + // Block ends when braces are balanced (and we have at least the header) + if brace_count <= 0 && start_line.is_some() { + let start = start_line? as u32; + let end = (line_idx as u32).min(start + max_lines as u32); + return Some((start, end)); + } + + // Hard cap + if line_idx >= start_line.unwrap_or(0) + max_lines { + let start = start_line? as u32; + return Some((start, start + max_lines as u32)); + } + } + + // If we found the start but never balanced braces, cap at max_lines + start_line.map(|s| { + ( + s as u32, + (s + max_lines.min(content.lines().count() - s + 1)) as u32, + ) + }) +} + +/// Count net brace delta: +1 for `{`, -1 for `}`. +fn count_braces_delta(line: &str) -> i32 { + let mut delta = 0i32; + let mut in_string = false; + let mut in_char = false; + let mut escape = false; + + for ch in line.chars() { + if escape { + escape = false; + continue; + } + if ch == '\\' && !in_char { + escape = true; + continue; + } + if ch == '"' && !in_char { + in_string = !in_string; + continue; + } + if ch == '\'' && !in_string && !in_char { + in_char = true; + continue; + } + if ch == '\'' && in_char { + in_char = false; + continue; + } + if in_string || in_char { + continue; + } + if ch == '{' { + delta += 1; + } else if ch == '}' { + delta -= 1; + } + } + delta +} + +/// Find the last line of a file's "definition block" (simplified). +fn find_definition_end(path: &Path) -> u32 { + if let Ok(content) = std::fs::read_to_string(path) { + let lines = content.lines().count(); + (lines as u32).min(MAX_TYPE_LINES as u32) + } else { + 1 + } +} + +/// Find a .go file in a package directory. +fn find_go_package_file(dir: &Path, import_path: &str) -> Option { + let files = std::fs::read_dir(dir).ok()?; + for file in files.flatten() { + let path = file.path(); + if path.extension().map(|e| e == "go").unwrap_or(false) { + let line_end = find_definition_end(&path); + let rel = path + .to_str() + .and_then(|p| p.rsplit_once('/').map(|x| x.0)) + .unwrap_or(import_path); + return Some(ContextEntry { + file: rel.to_string(), + line_start: 1, + line_end, + label: format!("package {import_path}"), + priority: ContextPriority::TypeDef, + }); + } + } + None +} + +/// Check if a file path matches any ignore pattern. +fn is_ignored(file: &str, patterns: &[String]) -> bool { + for pattern in patterns { + // Simple glob-like matching: check if file contains the pattern + // or matches as a suffix (e.g., "target/**" matches "target/debug/foo.rs") + let p = pattern.trim_end_matches("**"); + if p.is_empty() { + continue; + } + if file.starts_with(p.trim_end_matches('/')) || file.contains(p.trim_matches('*')) { + return true; + } + } + false +} + +/// Add test file mappings for changed source files. +fn add_test_mappings( + symbols: &[ExtractedSymbol], + project_root: &Path, + entries: &mut Vec, + stats: &mut ContextStats, +) { + // Collect unique source files from symbols + let mut seen = std::collections::HashSet::new(); + + for sym in symbols { + if !seen.insert(&sym.file) { + continue; + } + + let candidates = test_file_candidates(&sym.file); + for candidate in candidates { + let full = project_root.join(&candidate); + if full.exists() { + let line_end = find_definition_end(&full); + entries.push(ContextEntry { + file: candidate, + line_start: 1, + line_end, + label: format!("tests for {}", sym.file), + priority: ContextPriority::Test, + }); + stats.symbols_resolved += 1; + break; + } + } + } +} + +/// Generate test file candidate paths for a source file. +fn test_file_candidates(source: &str) -> Vec { + let mut candidates = Vec::new(); + + let stem = Path::new(source) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or(""); + + // Rust: `src/foo/bar.rs` โ†’ `tests/foo/bar_test.rs`, `tests/bar_test.rs` + // Also check: `tests/foo_test.rs` + if source.ends_with(".rs") { + candidates.push(format!("tests/{stem}_test.rs")); + if let Some(parent) = Path::new(source).parent() { + candidates.push(format!("tests/{}/{}", parent.display(), stem)); + candidates.push(format!("tests/{}/{}_test.rs", parent.display(), stem)); + } + candidates.push(format!("{stem}_test.rs")); + } + + // Python: `foo/bar.py` โ†’ `tests/test_bar.py`, `foo/test_bar.py` + if source.ends_with(".py") { + candidates.push(format!("tests/test_{stem}.py")); + if let Some(parent) = Path::new(source).parent() { + candidates.push(format!("{}/test_{stem}.py", parent.display())); + } + } + + // JS/TS: `src/foo.ts` โ†’ `src/foo.test.ts`, `tests/foo.test.ts` + if source.ends_with(".ts") || source.ends_with(".tsx") { + let ext = source.rsplit('.').next().unwrap_or("ts"); + candidates.push(format!("tests/{stem}.test.{ext}")); + candidates.push(format!("tests/{stem}.spec.{ext}")); + let without_ext = &source[..source.len() - ext.len() - 1]; + candidates.push(format!("{without_ext}.test.{ext}")); + candidates.push(format!("{without_ext}.spec.{ext}")); + } + + // Go: `foo.go` โ†’ `foo_test.go` + if source.ends_with(".go") { + candidates.push(source.replace(".go", "_test.go")); + } + + // Java/Kotlin + if source.ends_with(".java") { + candidates.push(source.replace(".java", "Test.java")); + } + if source.ends_with(".kt") { + candidates.push(source.replace(".kt", "Test.kt")); + } + + candidates +} + +/// Read the content for a context entry, respecting line range and caps. +fn read_entry_content(entry: &ContextEntry, project_root: &Path) -> String { + let full_path = project_root.join(&entry.file); + let content = match std::fs::read_to_string(&full_path) { + Ok(c) => c, + Err(e) => { + debug!(file = %entry.file, error = %e, "failed to read context entry file"); + return String::new(); + } + }; + + let start = entry.line_start.saturating_sub(1) as usize; + let end = entry.line_end as usize; + + let lines: Vec<&str> = content.lines().collect(); + let relevant: Vec<&str> = lines + .into_iter() + .skip(start) + .take(end.saturating_sub(start)) + .collect(); + + let result = relevant.join("\n"); + + // Apply cap based on priority + let max_lines = match entry.priority { + ContextPriority::FunctionDef => MAX_FN_LINES, + ContextPriority::TypeDef => MAX_TYPE_LINES, + ContextPriority::Test => MAX_TEST_LINES, + }; + + if result.lines().count() > max_lines { + result + .lines() + .take(max_lines) + .chain(std::iter::once("... (truncated)")) + .collect::>() + .join("\n") + } else { + result + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // โ”€โ”€โ”€ test_file_candidates โ”€โ”€โ”€ + + #[test] + fn rust_test_candidates() { + let candidates = test_file_candidates("src/engine/scanner.rs"); + assert!( + candidates.contains(&"tests/scanner_test.rs".to_string()), + "should generate tests/scanner_test.rs" + ); + assert!( + candidates.iter().any(|c| c.ends_with("engine/scanner")), + "should generate nested test path" + ); + } + + #[test] + fn python_test_candidates() { + let candidates = test_file_candidates("app/auth.py"); + assert!( + candidates.contains(&"tests/test_auth.py".to_string()), + "should generate tests/test_auth.py" + ); + } + + #[test] + fn js_test_candidates() { + let candidates = test_file_candidates("src/api.ts"); + assert!( + candidates.iter().any(|c| c.contains("api.test.ts")), + "should generate .test.ts candidate" + ); + } + + #[test] + fn go_test_candidates() { + let candidates = test_file_candidates("main.go"); + assert!( + candidates.contains(&"main_test.go".to_string()), + "should generate main_test.go" + ); + } + + // โ”€โ”€โ”€ is_ignored โ”€โ”€โ”€ + + #[test] + fn ignore_target_dir() { + assert!(is_ignored( + "target/debug/foo.rs", + &["target/**".to_string()] + )); + } + + #[test] + fn ignore_node_modules() { + assert!(is_ignored( + "node_modules/pkg/index.js", + &["node_modules/**".to_string()] + )); + } + + #[test] + fn not_ignored_src() { + assert!(!is_ignored("src/main.rs", &["target/**".to_string()])); + } + + // โ”€โ”€โ”€ count_braces_delta โ”€โ”€โ”€ + + #[test] + fn brace_delta_basic() { + assert_eq!(count_braces_delta("{ }"), 0); + assert_eq!(count_braces_delta("{{"), 2); + assert_eq!(count_braces_delta("}}"), -2); + assert_eq!(count_braces_delta("fn foo() {"), 1); + assert_eq!(count_braces_delta("}"), -1); + } + + #[test] + fn brace_delta_ignores_strings() { + assert_eq!(count_braces_delta(r#"let s = "{";"#), 0); + assert_eq!(count_braces_delta(r#"println!("{")");"#), 0); + } + + #[test] + fn brace_delta_ignores_comments() { + // Brace in comment shouldn't count... but our simple parser doesn't handle + // Rust comments. For now, it's a known limitation. The function handles + // string escaping correctly though. + assert_eq!(count_braces_delta(r#"let x = 1; // { }"#), 0); + } + + // โ”€โ”€โ”€ find_pattern_with_body โ”€โ”€โ”€ + + #[test] + fn find_simple_struct() { + let content = "fn main() {}\n\npub struct Foo {\n x: i32,\n}\n\nfn other() {}"; + let result = find_pattern_with_body(content, "struct Foo", 10); + assert_eq!(result, Some((3, 5))); + } + + #[test] + fn find_nested_function() { + let content = "fn outer() {\n fn inner() {\n 1\n }\n}\n\npub fn target() {\n let x = 1;\n return x;\n}\n"; + let result = find_pattern_with_body(content, "fn target", 10); + assert_eq!(result, Some((7, 10))); + } + + #[test] + fn find_pattern_not_found() { + let content = "fn main() {}\npub fn other() {}"; + let result = find_pattern_with_body(content, "fn missing", 10); + assert!(result.is_none()); + } + + // โ”€โ”€โ”€ build_context_chain integration โ”€โ”€โ”€ + + #[test] + fn disabled_config_returns_empty() { + let config = ContextConfig { + enabled: false, + ..Default::default() + }; + let chain = build_context_chain(&[], &config, Path::new("/tmp"), &[]); + assert!(chain.text.is_empty()); + } + + #[test] + fn empty_symbols_returns_empty() { + let config = ContextConfig::default(); + let chain = build_context_chain(&[], &config, Path::new("/tmp"), &[]); + assert!(chain.text.is_empty()); + } + + #[test] + fn budget_enforced() { + // Create a tiny budget + let config = ContextConfig { + enabled: true, + max_context_tokens: 5, // very small + follow_depth: 1, + include_tests: false, + }; + + let symbols = vec![ExtractedSymbol { + kind: SymbolKind::FunctionCall("some_func".to_string()), + file: "nonexistent.rs".to_string(), + line: 1, + raw: "some_func()".to_string(), + }]; + + let chain = build_context_chain(&symbols, &config, Path::new("/tmp"), &[]); + // Even if resolution finds nothing, the chain should be empty + assert!(chain.text.is_empty() || chain.stats.budget_hit); + } + + // โ”€โ”€โ”€ estimate_tokens consistency โ”€โ”€โ”€ + + #[test] + fn budget_accounting() { + let config = ContextConfig { + enabled: true, + max_context_tokens: 100, + follow_depth: 1, + include_tests: false, + }; + + let symbols = vec![ExtractedSymbol { + kind: SymbolKind::Import("engine::scanner".to_string()), + file: "src/main.rs".to_string(), + line: 1, + raw: "use crate::engine::scanner;".to_string(), + }]; + + let chain = build_context_chain(&symbols, &config, Path::new("/tmp"), &[]); + // With a nonexistent project root, nothing should resolve + assert!(chain.text.is_empty()); + assert_eq!(chain.stats.symbols_extracted, 1); + } +} diff --git a/src/engine/context/types.rs b/src/engine/context/types.rs new file mode 100644 index 00000000..4822c562 --- /dev/null +++ b/src/engine/context/types.rs @@ -0,0 +1,228 @@ +//! Context chain types โ€” symbols, resolved references, and configuration. +//! +//! The context chain enriches LLM review prompts with cross-file dependency +//! information extracted deterministically from changed code, zero LLM tokens. + +use serde::{Deserialize, Serialize}; + +/// Configuration for the context chain feature. +/// +/// Lives inside `Config` and is also deserializable from `.cora.yaml` +/// under the `review.context_chain` key. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContextConfig { + /// Whether the context chain is enabled. + /// When `false`, no cross-file context is collected. + #[serde(default = "default_true")] + pub enabled: bool, + + /// Maximum number of *tokens* of additional context to inject. + /// The budget is enforced via a rough `chars / 4` estimation. + /// Default: 3000 tokens โ‰ˆ 12 KB of code. + #[serde(default = "default_max_context_tokens")] + pub max_context_tokens: usize, + + /// Maximum levels of dependency following. + /// Depth 1 = resolve symbols from changed lines. + /// Depth 2 = also resolve symbols referenced in depth-1 results. + /// Depth 3+ follows further. Default: 1 (only direct references). + #[serde(default = "default_follow_depth")] + pub follow_depth: u32, + + /// Whether to auto-resolve test files via naming convention. + /// For Rust: `src/foo.rs` โ†’ `tests/foo_test.rs` or `tests/foo_test/`. + /// Default: true. + #[serde(default = "default_true")] + pub include_tests: bool, +} + +fn default_true() -> bool { + true +} + +fn default_max_context_tokens() -> usize { + 3000 +} + +fn default_follow_depth() -> u32 { + 1 +} + +impl Default for ContextConfig { + fn default() -> Self { + Self { + enabled: true, + max_context_tokens: 3000, + follow_depth: 1, + include_tests: true, + } + } +} + +/// A symbol extracted from source code in changed lines. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SymbolKind { + /// Function or method call (e.g., `validate_token()`, `self.handle()`). + FunctionCall(String), + /// Type or struct reference (e.g., `CryptoConfig`, `HashMap`). + TypeRef(String), + /// Import/use statement pointing to a module path + /// (e.g., `use crate::engine::scanner`). + Import(String), +} + +impl std::fmt::Display for SymbolKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SymbolKind::FunctionCall(name) => write!(f, "fn {name}"), + SymbolKind::TypeRef(name) => write!(f, "type {name}"), + SymbolKind::Import(path) => write!(f, "import {path}"), + } + } +} + +/// A single symbol extracted from a changed line, with source location. +#[derive(Debug, Clone)] +#[allow(dead_code)] // fields consumed in v0.5 when bundling wired +pub struct ExtractedSymbol { + /// What kind of symbol this is. + pub kind: SymbolKind, + /// The file path where the symbol was found. + pub file: String, + /// Line number in the file (1-indexed). + pub line: u32, + /// The raw text that matched (for debugging). + pub raw: String, +} + +/// A resolved context entry โ€” a (file, line range, symbol) tuple ready +/// for reading and injection into the prompt. +#[derive(Debug, Clone)] +pub struct ContextEntry { + /// Resolved file path (relative to project root). + pub file: String, + /// Start line (1-indexed, inclusive). + pub line_start: u32, + /// End line (1-indexed, inclusive). + pub line_end: u32, + /// Human-readable label (e.g., `fn validate_token`, `struct CryptoConfig`). + pub label: String, + /// Priority tier for budget allocation. + pub priority: ContextPriority, +} + +/// Priority of a context entry โ€” controls which entries are included +/// first when the token budget is limited. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum ContextPriority { + /// Function/method definitions โ€” highest priority. + FunctionDef = 0, + /// Type/struct definitions. + TypeDef = 1, + /// Test functions โ€” lowest priority. + Test = 2, +} + +/// Statistics from a context chain build, useful for logging / progress events. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ContextStats { + /// Number of symbols extracted from changed lines. + pub symbols_extracted: usize, + /// Number of symbols successfully resolved to file locations. + pub symbols_resolved: usize, + /// Number of context entries read from disk. + pub entries_read: usize, + /// Estimated token count of the assembled context. + pub estimated_tokens: usize, + /// Whether the budget was hit (some entries were dropped). + pub budget_hit: bool, +} + +/// The fully assembled context chain, ready for prompt injection. +#[derive(Debug, Clone, Default)] +#[allow(dead_code)] // stats consumed in v0.5 when bundling wired +pub struct ContextChain { + /// Formatted text to inject into the prompt. + pub text: String, + /// Build statistics. + pub stats: ContextStats, +} + +/// Rough token estimation: ~4 characters per token. +/// This is a heuristic โ€” consistent across runs, which matters more than accuracy. +pub fn estimate_tokens(text: &str) -> usize { + text.len() / 4 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn context_config_default() { + let cfg = ContextConfig::default(); + assert!(cfg.enabled); + assert_eq!(cfg.max_context_tokens, 3000); + assert_eq!(cfg.follow_depth, 1); + assert!(cfg.include_tests); + } + + #[test] + fn context_config_deserialize_partial() { + let yaml = "enabled: false\nmax_context_tokens: 5000\n"; + let cfg: ContextConfig = serde_yaml_ng::from_str(yaml).unwrap(); + assert!(!cfg.enabled); + assert_eq!(cfg.max_context_tokens, 5000); + // Defaults should fill in for missing fields + assert_eq!(cfg.follow_depth, 1); + assert!(cfg.include_tests); + } + + #[test] + fn context_config_deserialize_full() { + let yaml = + "enabled: true\nmax_context_tokens: 2000\nfollow_depth: 3\ninclude_tests: false\n"; + let cfg: ContextConfig = serde_yaml_ng::from_str(yaml).unwrap(); + assert!(cfg.enabled); + assert_eq!(cfg.max_context_tokens, 2000); + assert_eq!(cfg.follow_depth, 3); + assert!(!cfg.include_tests); + } + + #[test] + fn estimate_tokens_basic() { + // 40 chars โ†’ ~10 tokens + assert_eq!( + estimate_tokens("hello world this is a test string xyz123"), + 10 + ); + } + + #[test] + fn estimate_tokens_empty() { + assert_eq!(estimate_tokens(""), 0); + } + + #[test] + fn estimate_tokens_short() { + // 3 chars โ†’ 0 tokens (integer division) + assert_eq!(estimate_tokens("abc"), 0); + } + + #[test] + fn symbol_kind_display() { + let fc = format!("{}", SymbolKind::FunctionCall("foo".into())); + assert_eq!(fc, "fn foo"); + let tr = format!("{}", SymbolKind::TypeRef("Bar".into())); + assert_eq!(tr, "type Bar"); + let im = format!("{}", SymbolKind::Import("crate::mod".into())); + assert_eq!(im, "import crate::mod"); + } + + #[test] + fn context_priority_ordering() { + // FunctionDef < TypeDef < Test (Ord โ€” smaller = higher priority) + assert!(ContextPriority::FunctionDef < ContextPriority::TypeDef); + assert!(ContextPriority::TypeDef < ContextPriority::Test); + } +} diff --git a/src/engine/diff_parser.rs b/src/engine/diff_parser.rs new file mode 100644 index 00000000..28eaec07 --- /dev/null +++ b/src/engine/diff_parser.rs @@ -0,0 +1,569 @@ +/// Unified diff parser โ€” converts raw diff text into structured [`FileChunk`] data. +/// +/// Simple regex-based parser that handles: +/// - Added, modified, deleted, and binary files +/// - Renames (old โ†’ new path) +/// - Multi-hunk diffs with line tracking +/// - Language detection from file extensions +use regex::Regex; +use std::sync::LazyLock; +use tracing::debug; + +/// A single file's worth of changes in a diff. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FileChunk { + /// Old file path (from `--- a/path`). `None` for new files. + pub old_path: Option, + /// New file path (from `+++ b/path`). `None` for deleted files. + pub new_path: Option, + /// Language detected from file extension (e.g., `"rs"`, `"py"`). + pub language: String, + /// The `@@ ... @@` hunks inside this file. + pub chunks: Vec, + /// Whether this is a binary file change. + pub is_binary: bool, + /// Whether this file was deleted. + pub is_deleted: bool, + /// Whether this file is newly created. + pub is_new: bool, +} + +/// A single `@@ ... @@` hunk block. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DiffHunk { + pub old_start: u32, + pub old_count: u32, + pub new_start: u32, + pub new_count: u32, + /// The header text after the `@@` markers (function context). + pub header: String, + /// Individual diff lines within this hunk. + pub lines: Vec, +} + +/// A single line inside a diff hunk. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DiffLine { + /// Whether this line was added, removed, or is context. + pub line_type: DiffLineType, + /// Content stripped of the leading `+`/`-`/` ` prefix. + pub content: String, + /// Line number in the old file (for `Remove` and `Context` lines). + pub old_line_no: Option, + /// Line number in the new file (for `Add` and `Context` lines). + pub new_line_no: Option, +} + +/// Type of a diff line. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DiffLineType { + Add, + Remove, + Context, +} + +// โ”€โ”€โ”€ Regex patterns (compiled once) โ”€โ”€โ”€ + +static RE_FILE_HEADER_OLD: LazyLock = + LazyLock::new(|| Regex::new(r"^---\s+(?:a/)?(.*)").unwrap()); +static RE_FILE_HEADER_NEW: LazyLock = + LazyLock::new(|| Regex::new(r"^\+\+\+\s+(?:b/)?(.*)").unwrap()); +static RE_HUNK_HEADER: LazyLock = + LazyLock::new(|| Regex::new(r"^@@\s-(\d+)(?:,(\d+))?\s\+(\d+)(?:,(\d+))?\s@@\s?(.*)").unwrap()); +static RE_BINARY: LazyLock = LazyLock::new(|| Regex::new(r"^Binary files").unwrap()); + +/// Parse a unified diff string into a list of [`FileChunk`] entries. +pub fn parse_diff(diff: &str) -> Vec { + let mut files = Vec::new(); + let mut current: Option = None; + + for line in diff.lines() { + // Check for file header pair (--- / +++) + if let Some(caps) = RE_FILE_HEADER_OLD.captures(line) { + // If we have a pending file, flush it + if let Some(builder) = current.take() { + files.push(builder.build()); + } + let raw_path = caps[1].trim_end_matches('\t').to_string(); + let is_new = raw_path == "/dev/null"; + + let mut builder = FileChunkBuilder::new(); + builder.old_path = if is_new { None } else { Some(raw_path) }; + builder.is_new = is_new; + current = Some(builder); + continue; + } + + if let Some(ref mut builder) = current { + if let Some(caps) = RE_FILE_HEADER_NEW.captures(line) { + let raw_path = caps[1].trim_end_matches('\t').to_string(); + let is_deleted = raw_path == "/dev/null"; + + builder.new_path = if is_deleted { None } else { Some(raw_path) }; + builder.is_deleted = is_deleted; + // Determine language from the new path (preferred) or old path + let lang_path = builder.new_path.as_deref().or(builder.old_path.as_deref()); + if let Some(p) = lang_path { + builder.language = detect_language(p).to_string(); + } + continue; + } + } + + // Inside a file section, check for hunks and lines + if let Some(ref mut builder) = current { + // Check for binary marker (before hunk check โ€” binary files have no hunks) + if RE_BINARY.is_match(line) { + builder.is_binary = true; + continue; + } + + if let Some(caps) = RE_HUNK_HEADER.captures(line) { + // Flush previous hunk (multi-hunk support) + if let Some(hunk) = builder.current_hunk.take() { + builder.hunks.push(hunk); + } + + let old_start: u32 = caps[1].parse().unwrap_or(1); + let old_count: u32 = caps[2].parse().unwrap_or(1); + let new_start: u32 = caps[3].parse().unwrap_or(1); + let new_count: u32 = caps[4].parse().unwrap_or(1); + let header = caps[5].to_string(); + + // Initialize line counters from hunk header + builder.old_line = old_start; + builder.new_line = new_start; + + builder.current_hunk = Some(DiffHunk { + old_start, + old_count, + new_start, + new_count, + header, + lines: Vec::new(), + }); + continue; + } + + if let Some(ref mut hunk) = builder.current_hunk { + // Parse diff lines + let (line_type, content) = if let Some(rest) = line.strip_prefix('+') { + (DiffLineType::Add, rest.to_string()) + } else if let Some(rest) = line.strip_prefix('-') { + (DiffLineType::Remove, rest.to_string()) + } else if let Some(rest) = line.strip_prefix(' ') { + (DiffLineType::Context, rest.to_string()) + } else { + // Unknown line (e.g., "diff --git ..."), skip + continue; + }; + + // Compute line numbers based on line type, then increment counters + let (old_line_no, new_line_no) = match line_type { + DiffLineType::Add => { + let no = if builder.new_line > 0 { + Some(builder.new_line) + } else { + None + }; + builder.new_line += 1; + (None, no) + } + DiffLineType::Remove => { + let no = if builder.old_line > 0 { + Some(builder.old_line) + } else { + None + }; + builder.old_line += 1; + (no, None) + } + DiffLineType::Context => { + let old_no = if builder.old_line > 0 { + Some(builder.old_line) + } else { + None + }; + let new_no = if builder.new_line > 0 { + Some(builder.new_line) + } else { + None + }; + builder.old_line += 1; + builder.new_line += 1; + (old_no, new_no) + } + }; + + hunk.lines.push(DiffLine { + line_type, + content, + old_line_no, + new_line_no, + }); + } + } + } + + // Flush the last file + if let Some(builder) = current.take() { + files.push(builder.build()); + } + + debug!(file_count = files.len(), "parsed diff into file chunks"); + files +} + +/// Detect programming language from a file path's extension. +/// +/// Returns the language shorthand (e.g., `"rs"`, `"py"`, `"ts"`). +/// Falls back to `"unknown"` for unrecognized extensions. +pub fn detect_language(path: &str) -> &'static str { + match path.rsplit('.').next() { + Some(ext) => match ext.to_lowercase().as_str() { + "rs" => "rs", + "py" | "pyi" => "py", + "ts" | "tsx" => "ts", + "js" | "jsx" | "mjs" | "cjs" => "js", + "go" => "go", + "java" => "java", + "kt" | "kts" => "kt", + "c" => "c", + "cpp" | "cc" | "cxx" => "cpp", + "h" | "hpp" | "hxx" => "h", + "rb" => "rb", + "php" => "php", + "swift" => "swift", + "scala" => "scala", + "r" => "r", + "sql" => "sql", + "sh" | "bash" | "zsh" => "sh", + "toml" => "toml", + "yaml" | "yml" => "yaml", + "json" => "json", + "md" | "markdown" => "md", + "html" | "htm" => "html", + "css" | "scss" | "sass" | "less" => "css", + "dart" => "dart", + "lua" => "lua", + "zig" => "zig", + "ex" | "exs" => "ex", + "proto" => "proto", + "graphql" | "gql" => "graphql", + "dockerfile" => "dockerfile", + // Non-code extensions (still recognized for exclusion/filtering) + "png" | "jpg" | "jpeg" | "gif" | "svg" | "ico" | "webp" => "image", + "pdf" | "doc" | "docx" => "document", + "lock" | "sum" => "lock", + _ => "unknown", + }, + None => "unknown", + } +} + +/// Extract all added lines from parsed file chunks. +/// +/// Returns `(file_path, line_number, content)` triples. +#[allow(dead_code)] // used by bundling in v0.5 +pub fn extract_added_lines(chunks: &[FileChunk]) -> Vec<(String, u32, String)> { + let mut result = Vec::new(); + + for file in chunks { + let path = file + .new_path + .as_deref() + .or(file.old_path.as_deref()) + .unwrap_or("unknown"); + for hunk in &file.chunks { + for line in &hunk.lines { + if line.line_type == DiffLineType::Add { + if let Some(ln) = line.new_line_no { + result.push((path.to_string(), ln, line.content.clone())); + } + } + } + } + } + + result +} + +// โ”€โ”€โ”€ Internal builder for assembling FileChunk โ”€โ”€โ”€ + +struct FileChunkBuilder { + old_path: Option, + new_path: Option, + language: String, + current_hunk: Option, + hunks: Vec, + old_line: u32, + new_line: u32, + is_binary: bool, + is_deleted: bool, + is_new: bool, +} + +impl FileChunkBuilder { + fn new() -> Self { + Self { + old_path: None, + new_path: None, + language: "unknown".to_string(), + current_hunk: None, + hunks: Vec::new(), + old_line: 0, + new_line: 0, + is_binary: false, + is_deleted: false, + is_new: false, + } + } + + fn build(mut self) -> FileChunk { + if let Some(hunk) = self.current_hunk.take() { + self.hunks.push(hunk); + } + FileChunk { + old_path: self.old_path, + new_path: self.new_path, + language: self.language, + chunks: self.hunks, + is_binary: self.is_binary, + is_deleted: self.is_deleted, + is_new: self.is_new, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_simple_diff() { + let diff = r#"diff --git a/src/main.rs b/src/main.rs +--- a/src/main.rs ++++ b/src/main.rs +@@ -1,3 +1,4 @@ + use std::io; + fn main() { ++ println!("hello"); + } +"#; + let files = parse_diff(diff); + assert_eq!(files.len(), 1); + let f = &files[0]; + assert_eq!(f.old_path.as_deref(), Some("src/main.rs")); + assert_eq!(f.new_path.as_deref(), Some("src/main.rs")); + assert_eq!(f.language, "rs"); + assert_eq!(f.chunks.len(), 1); + assert_eq!(f.chunks[0].lines.len(), 4); + + let added: Vec<_> = f.chunks[0] + .lines + .iter() + .filter(|l| l.line_type == DiffLineType::Add) + .collect(); + assert_eq!(added.len(), 1); + assert!(added[0].content.contains("println")); + } + + #[test] + fn parse_new_file() { + let diff = r#"diff --git a/src/new_file.py b/src/new_file.py +new file mode 100644 +--- /dev/null ++++ b/src/new_file.py +@@ -0,0 +1,5 @@ ++import os ++ ++def main(): ++ pass ++ +"#; + let files = parse_diff(diff); + assert_eq!(files.len(), 1); + let f = &files[0]; + assert!(f.is_new); + assert!(!f.is_deleted); + assert!(f.old_path.is_none()); + assert_eq!(f.new_path.as_deref(), Some("src/new_file.py")); + assert_eq!(f.language, "py"); + } + + #[test] + fn parse_deleted_file() { + let diff = r#"diff --git a/old_file.go b/old_file.go +deleted file mode 100644 +--- a/old_file.go ++++ /dev/null +@@ -1,3 +0,0 @@ +-package main +- +-func deleted() {} +"#; + let files = parse_diff(diff); + assert_eq!(files.len(), 1); + let f = &files[0]; + assert!(f.is_deleted); + assert!(!f.is_new); + assert!(f.new_path.is_none()); + assert_eq!(f.old_path.as_deref(), Some("old_file.go")); + assert_eq!(f.language, "go"); + } + + #[test] + fn parse_binary_file() { + let diff = r#"diff --git a/image.png b/image.png +--- a/image.png ++++ b/image.png +Binary files a/image.png and b/image.png differ +"#; + let files = parse_diff(diff); + assert_eq!(files.len(), 1); + let f = &files[0]; + assert!(f.is_binary); + assert_eq!(f.language, "image"); + assert!(f.chunks.is_empty()); + } + + #[test] + fn parse_multifile_diff() { + let diff = r#"diff --git a/lib.rs b/lib.rs +--- a/lib.rs ++++ b/lib.rs +@@ -1,1 +1,2 @@ + pub mod foo; ++pub mod bar; +diff --git a/src/foo.rs b/src/foo.rs +--- a/src/foo.rs ++++ b/src/foo.rs +@@ -1,3 +1,3 @@ + fn foo() { +- old(); ++ new(); + } +"#; + let files = parse_diff(diff); + assert_eq!(files.len(), 2); + assert_eq!(files[0].new_path.as_deref(), Some("lib.rs")); + assert_eq!(files[1].new_path.as_deref(), Some("src/foo.rs")); + } + + #[test] + fn parse_multi_hunk_diff() { + let diff = r#"diff --git a/src/main.rs b/src/main.rs +--- a/src/main.rs ++++ b/src/main.rs +@@ -1,3 +1,3 @@ + fn a() { +- old_a(); ++ new_a(); + } +@@ -10,3 +10,3 @@ + fn b() { +- old_b(); ++ new_b(); + } +"#; + let files = parse_diff(diff); + assert_eq!(files.len(), 1); + assert_eq!(files[0].chunks.len(), 2); + assert_eq!(files[0].chunks[0].old_start, 1); + assert_eq!(files[0].chunks[1].old_start, 10); + } + + #[test] + fn line_numbers_tracked() { + let diff = r#"diff --git a/test.ts b/test.ts +--- a/test.ts ++++ b/test.ts +@@ -5,4 +5,5 @@ + const x = 1; +-const y = 2; ++const y = 3; ++const z = 4; + const w = 5; +"#; + let files = parse_diff(diff); + let hunk = &files[0].chunks[0]; + // Context line at old=5, new=5 + let ctx = &hunk.lines[0]; + assert_eq!(ctx.line_type, DiffLineType::Context); + assert_eq!(ctx.old_line_no, Some(5)); + assert_eq!(ctx.new_line_no, Some(5)); + // Removed line at old=6 + let rem = &hunk.lines[1]; + assert_eq!(rem.line_type, DiffLineType::Remove); + assert_eq!(rem.old_line_no, Some(6)); + assert!(rem.new_line_no.is_none()); + // First added line at new=6 + let add1 = &hunk.lines[2]; + assert_eq!(add1.line_type, DiffLineType::Add); + assert!(add1.old_line_no.is_none()); + assert_eq!(add1.new_line_no, Some(6)); + // Second added line at new=7 + let add2 = &hunk.lines[3]; + assert_eq!(add2.new_line_no, Some(7)); + } + + #[test] + fn extract_added_lines_works() { + let diff = r#"diff --git a/app.js b/app.js +--- a/app.js ++++ b/app.js +@@ -1,1 +1,3 @@ + console.log('a'); ++console.log('b'); ++console.log('c'); +"#; + let files = parse_diff(diff); + let added = extract_added_lines(&files); + assert_eq!(added.len(), 2); + assert_eq!(added[0].0, "app.js"); + assert_eq!(added[0].1, 2); + assert_eq!(added[1].1, 3); + } + + #[test] + fn detect_language_various() { + assert_eq!(detect_language("src/main.rs"), "rs"); + assert_eq!(detect_language("script.py"), "py"); + assert_eq!(detect_language("index.ts"), "ts"); + assert_eq!(detect_language("app.tsx"), "ts"); + assert_eq!(detect_language("server.js"), "js"); + assert_eq!(detect_language("main.go"), "go"); + assert_eq!(detect_language("App.java"), "java"); + assert_eq!(detect_language("Makefile"), "unknown"); + assert_eq!(detect_language("path/to/no_ext"), "unknown"); + assert_eq!(detect_language("style.scss"), "css"); + } + + #[test] + fn empty_diff_returns_empty() { + let files = parse_diff(""); + assert!(files.is_empty()); + } + + #[test] + fn hunk_header_with_function_context() { + let diff = r#"diff --git a/lib.rs b/lib.rs +--- a/lib.rs ++++ b/lib.rs +@@ -10,7 +10,8 @@ pub fn process(input: &str) { + let data = parse(input); +- consume(data); ++ let result = transform(data); ++ consume(result); + } +"#; + let files = parse_diff(diff); + assert_eq!(files.len(), 1); + let hunk = &files[0].chunks[0]; + assert_eq!(hunk.old_start, 10); + assert_eq!(hunk.old_count, 7); + assert_eq!(hunk.new_start, 10); + assert_eq!(hunk.new_count, 8); + assert!(hunk.header.contains("pub fn process")); + } +} diff --git a/src/engine/llm.rs b/src/engine/llm.rs index 7dd14747..9e700150 100644 --- a/src/engine/llm.rs +++ b/src/engine/llm.rs @@ -1,12 +1,51 @@ -use anyhow::{Context, Result}; +use crate::error::CoraError; use indicatif::{ProgressBar, ProgressStyle}; use serde::{Deserialize, Serialize}; use serde_json::Value; +use std::sync::LazyLock; use tracing::debug; -use crate::engine::types::{ - LLMConfig, ReviewIssue, ReviewResponse, TokenUsage, -}; +use crate::engine::types::{LLMConfig, ReviewIssue, ReviewResponse, TokenUsage}; + +/// Shared `reqwest::Client` with connection pooling. Reused across all LLM requests. +/// Created lazily on first use to avoid blocking initialization. +/// Per-request timeout is set via .`timeout()` on the `RequestBuilder`. +/// +/// Supports `REQUESTS_CA_BUNDLE` env var for custom CA certificates +/// (corporate proxies with self-signed certs). +static SHARED_CLIENT: LazyLock = LazyLock::new(|| { + let mut builder = reqwest::Client::builder().pool_max_idle_per_host(4); + + // Support custom CA certificates for corporate proxies. + // REQUESTS_CA_BUNDLE is the de-facto standard used by Python requests, + // curl, Node.js, and most HTTP tooling. + if let Ok(ca_path) = std::env::var("REQUESTS_CA_BUNDLE") { + match std::fs::read(&ca_path) { + Ok(ca_data) => match reqwest::Certificate::from_pem(&ca_data) { + Ok(cert) => { + builder = builder.add_root_certificate(cert); + tracing::debug!("loaded custom CA bundle from REQUESTS_CA_BUNDLE"); + } + Err(e) => { + tracing::warn!("failed to parse CA bundle {}: {}", ca_path, e); + } + }, + Err(e) => { + tracing::warn!("failed to read CA bundle {}: {}", ca_path, e); + } + } + } + + builder.build().unwrap_or_else(|e| { + tracing::error!("failed to build shared HTTP client: {}", e); + reqwest::Client::new() + }) +}); + +/// Return the shared `reqwest::Client` for LLM API requests. +pub fn shared_client() -> reqwest::Client { + SHARED_CLIENT.clone() +} /// OpenAI-compatible chat message. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -38,7 +77,12 @@ struct ChatChoice { message: ChatMessage, } +/// Usage statistics from the LLM API response. +/// `prompt_tokens` and `completion_tokens` are kept for API completeness +/// (deserialization) even though only `total_tokens` is currently accessed. +#[allow(dead_code)] #[derive(Debug, Clone, Deserialize)] +#[allow(clippy::struct_field_names)] struct Usage { prompt_tokens: u32, completion_tokens: u32, @@ -46,41 +90,66 @@ struct Usage { } /// System prompt for code review. -const REVIEW_SYSTEM_PROMPT: &str = r#"You are an expert code reviewer. Your job is to analyze code diffs and identify issues. +const REVIEW_SYSTEM_PROMPT: &str = r#"You are an expert code reviewer providing actionable feedback on code diffs. + +CRITICAL CONSTRAINTS: +1. You MUST ONLY comment on files that appear in the diff. Do NOT invent or hallucinate file paths. +2. Each issue MUST have a clear, descriptive title (one brief sentence, max 100 chars). +3. If uncertain whether something is a real issue, omit it rather than guessing. + +SEVERITY LEVELS: +- "critical": Security vulnerabilities, crashes, data loss, breaking bugs +- "major": Bugs that affect functionality, significant problems +- "minor": Style issues, small nitpicks, minor improvements +- "info": Suggestions, optional enhancements -Focus on these categories: -- Security vulnerabilities (injections, auth issues, data exposure) -- Performance problems (inefficient algorithms, memory leaks, N+1 queries) -- Bugs (logic errors, off-by-one, null/undefined handling) -- Best practices (idiomatic code, error handling, naming) +FOCUS AREAS (in priority order): +1. Security vulnerabilities (SQL injection, XSS, auth issues, data exposure) +2. Bugs and logic errors (off-by-one, null handling, race conditions) +3. Performance problems (inefficient algorithms, memory leaks, N+1 queries) +4. Best practices (idiomatic code, error handling, naming) -For each issue found, return a JSON array of objects with these fields: -- "file": string โ€” the file path +RESPONSE FORMAT: +Return a JSON array of objects with these fields: +- "file": string โ€” the file path (MUST be from the diff) - "line": number or null โ€” the approximate line number - "severity": "critical" | "major" | "minor" | "info" -- "issue_type": string โ€” category (security, performance, bugs, best-practice, style) +- "issue_type": string โ€” category (security, performance, bugs, best_practice, style, suggestion) - "title": string โ€” short description (max 100 chars) - "body": string โ€” detailed explanation - "suggested_fix": string or null โ€” optional fix suggestion -If no issues are found, return an empty array: [] +If no issues are found, return: [] -Return ONLY the JSON array. No markdown, no explanation, just the JSON."#; +Return ONLY the JSON array. No markdown code fences, no explanation, no conversational text. +Start with [ and end with ]."#; /// System prompt for full project scanning. const SCAN_SYSTEM_PROMPT: &str = r#"You are an expert code reviewer performing a full project scan. Analyze the provided code files and identify issues. -Focus on these categories: -- Security vulnerabilities -- Performance problems -- Bugs and logic errors -- Best practices and code quality +CRITICAL CONSTRAINTS: +1. You MUST ONLY comment on files that were provided to you. Do NOT invent file paths. +2. Each issue MUST have a clear, descriptive title (one brief sentence, max 100 chars). +3. If uncertain whether something is a real issue, omit it rather than guessing. -For each issue found, return a JSON array of objects with these fields: -- "file": string โ€” the file path +SEVERITY LEVELS: +- "critical": Security vulnerabilities, crashes, data loss, breaking bugs +- "major": Bugs that affect functionality, significant problems +- "minor": Style issues, small nitpicks, minor improvements +- "info": Suggestions, optional enhancements + +FOCUS AREAS (in priority order): +1. Security vulnerabilities (SQL injection, XSS, auth issues, data exposure) +2. Bugs and logic errors (off-by-one, null handling, race conditions) +3. Performance problems (inefficient algorithms, memory leaks, N+1 queries) +4. Best practices (idiomatic code, error handling, naming) + +RESPONSE FORMAT: +Return a JSON array of objects with these fields: +- "file": string โ€” the file path (MUST be from the provided files) - "line": number or null โ€” the approximate line number - "severity": "critical" | "major" | "minor" | "info" -- "issue_type": string โ€” category +- "issue_type": string โ€” category (security, performance, bugs, best_practice, style, suggestion) - "title": string โ€” short description (max 100 chars) - "body": string โ€” detailed explanation - "suggested_fix": string or null โ€” optional fix suggestion @@ -90,7 +159,8 @@ Also include a "summary" string at the end after a "|||" separator: If no issues are found, return: []|||No issues found. -Return ONLY this format. No markdown."#; +Return ONLY this format. No markdown code fences, no conversational text. +Start the JSON array with [ and end with ]."#; /// Send a chat completion request to an OpenAI-compatible API. async fn chat_completion( @@ -98,16 +168,17 @@ async fn chat_completion( system_prompt: &str, user_message: &str, spinner: Option<&ProgressBar>, -) -> Result { - let client = reqwest::Client::new(); + response_format: &str, +) -> std::result::Result { + let client = shared_client(); - let url = format!( - "{}/chat/completions", - config.base_url.trim_end_matches('/') - ); + let url = format!("{}/chat/completions", config.base_url.trim_end_matches('/')); if let Some(sp) = spinner { - sp.set_message(format!("Sending to {} ({})โ€ฆ", config.provider, config.model)); + sp.set_message(format!( + "Sending to {} ({})โ€ฆ", + config.provider, config.model + )); } let request = ChatRequest { @@ -122,9 +193,13 @@ async fn chat_completion( content: user_message.into(), }, ], - temperature: 0.2, - max_tokens: 4096, - response_format: None, + temperature: config.temperature, + max_tokens: config.max_tokens, + response_format: if response_format == "json_object" { + Some(serde_json::json!({"type": "json_object"})) + } else { + None + }, }; debug!(model = %config.model, url = %url, "sending LLM request"); @@ -134,17 +209,19 @@ async fn chat_completion( .header("Authorization", format!("Bearer {}", config.api_key)) .header("Content-Type", "application/json") .json(&request) + .timeout(std::time::Duration::from_secs(config.timeout)) .send() .await - .context("LLM API request failed")?; + .map_err(CoraError::LlmRequest)?; let status = response.status(); - let body = response.text().await.context("failed to read LLM response body")?; + let body = response.text().await.map_err(CoraError::LlmRequest)?; if !status.is_success() { - anyhow::bail!( - "LLM API returned status {status}: {body}", - ); + return Err(CoraError::LlmStatus { + status: status.as_u16(), + body, + }); } if let Some(sp) = spinner { @@ -152,7 +229,7 @@ async fn chat_completion( } let parsed: ChatResponse = - serde_json::from_str(&body).context(format!("failed to parse LLM JSON response: {body}"))?; + serde_json::from_str(&body).map_err(|e| CoraError::LlmParse(format!("{e}: {body}")))?; let content = parsed .choices @@ -172,7 +249,7 @@ fn create_spinner(message: &str) -> ProgressBar { spinner.enable_steady_tick(std::time::Duration::from_millis(80)); spinner.set_style( ProgressStyle::with_template("{spinner:.cyan} {msg}") - .unwrap() + .expect("valid spinner template") .tick_chars("โ โ ‚โ „โก€โข€โ  โ โ ˆ "), ); spinner.set_message(message.to_string()); @@ -180,38 +257,265 @@ fn create_spinner(message: &str) -> ProgressBar { } /// Review a diff using the LLM. Returns a `ReviewResponse`. +#[allow(clippy::too_many_arguments)] pub async fn review_diff( llm_config: &LLMConfig, diff: &str, focus: &[String], rules: &[String], -) -> Result { - let spinner = create_spinner("Reviewing diffโ€ฆ"); + response_format: &str, + system_prompt_override: Option<&str>, + quiet: bool, + static_context: Option<&str>, +) -> std::result::Result { + let spinner = if quiet { + None + } else { + Some(create_spinner("Reviewing diffโ€ฆ")) + }; + + let user_prompt = build_review_prompt(diff, focus, rules, static_context); - let user_prompt = build_review_prompt(diff, focus, rules); + let system_prompt = system_prompt_override.unwrap_or(REVIEW_SYSTEM_PROMPT); - let raw = chat_completion(llm_config, REVIEW_SYSTEM_PROMPT, &user_prompt, Some(&spinner)).await?; + let raw = chat_completion( + llm_config, + system_prompt, + &user_prompt, + spinner.as_ref(), + response_format, + ) + .await?; + + let parse_result = parse_review_response(&raw); + match parse_result { + Ok(result) => { + if let Some(sp) = spinner { + sp.finish_and_clear(); + } + Ok(ReviewResponse { + issues: result.0, + summary: result.1, + tokens_used: result.2, + should_block: false, + }) + } + Err(e) => { + // LLM produced invalid JSON โ€” retry once with stricter prompt + debug!(error = %e, "first parse attempt failed, retrying LLM request"); + if let Some(sp) = &spinner { + sp.set_message("Retrying (parse error)โ€ฆ"); + } + let strict_prompt = format!( + "{}\n\nIMPORTANT: Your response MUST contain only valid JSON. \ + Ensure all strings use proper JSON escape sequences. \ + Do NOT use raw backslashes in string values.", + &user_prompt + ); + let retry_raw = chat_completion( + llm_config, + system_prompt, + &strict_prompt, + spinner.as_ref(), + response_format, + ) + .await?; + let (issues, summary, tokens_used) = parse_review_response(&retry_raw)?; + if let Some(sp) = spinner { + sp.finish_and_clear(); + } + Ok(ReviewResponse { + issues, + summary, + tokens_used, + should_block: false, + }) + } + } +} + +/// Review a diff using the LLM with streaming. Returns a `ReviewResponse`. +/// +/// Streams tokens from the LLM and prints them to stdout in real-time, +/// then collects the full response for parsing. +#[allow(clippy::too_many_arguments)] +pub async fn review_diff_stream( + llm_config: &LLMConfig, + diff: &str, + focus: &[String], + rules: &[String], + response_format: &str, + system_prompt_override: Option<&str>, + static_context: Option<&str>, +) -> std::result::Result { + let user_prompt = build_review_prompt(diff, focus, rules, static_context); + + let system_prompt = system_prompt_override.unwrap_or(REVIEW_SYSTEM_PROMPT); + + let raw = + chat_completion_stream(llm_config, system_prompt, &user_prompt, response_format).await?; let (issues, summary, tokens_used) = parse_review_response(&raw)?; - spinner.finish_and_clear(); + println!(); // trailing newline after streamed output + Ok(ReviewResponse { issues, summary, tokens_used, - should_block: false, // caller sets this based on config + should_block: false, }) } +/// Send a streaming chat completion request to an OpenAI-compatible API. +/// +/// Sends `"stream": true` in the request body, reads SSE chunks, prints +/// delta content to stdout in real-time, and returns the full accumulated text. +#[allow(clippy::too_many_lines)] +async fn chat_completion_stream( + config: &LLMConfig, + system_prompt: &str, + user_message: &str, + response_format: &str, +) -> std::result::Result { + use futures_util::StreamExt; + use std::io::Write; + + let client = shared_client(); + let url = format!("{}/chat/completions", config.base_url.trim_end_matches('/')); + + let mut request_body = serde_json::json!({ + "model": config.model, + "messages": [ + { "role": "system", "content": system_prompt }, + { "role": "user", "content": user_message } + ], + "temperature": config.temperature, + "max_tokens": config.max_tokens, + "stream": true + }); + + if response_format == "json_object" { + request_body["response_format"] = serde_json::json!({"type": "json_object"}); + } + + debug!(model = %config.model, url = %url, "sending streaming LLM request"); + + let response = client + .post(&url) + .header("Authorization", format!("Bearer {}", config.api_key)) + .header("Content-Type", "application/json") + .json(&request_body) + .timeout(std::time::Duration::from_secs(config.timeout)) + .send() + .await + .map_err(CoraError::LlmRequest)?; + + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(CoraError::LlmStatus { + status: status.as_u16(), + body, + }); + } + + let mut stream = response.bytes_stream(); + + // Buffer for assembling lines from byte chunks + let mut line_buf = String::new(); + let mut accumulated = String::new(); + + while let Some(chunk_result) = stream.next().await { + let chunk = chunk_result.map_err(|e| CoraError::LlmStream(e.to_string()))?; + let chunk_str = String::from_utf8_lossy(&chunk); + + // Process the chunk character by character to handle line boundaries + for ch in chunk_str.chars() { + if ch == '\n' { + let line = line_buf.trim().to_string(); + line_buf.clear(); + + if line.is_empty() || line.starts_with(':') { + continue; + } + + if let Some(data) = line.strip_prefix("data: ") { + if data.trim() == "[DONE]" { + debug!(accumulated_len = accumulated.len(), "streaming complete"); + return Ok(accumulated); + } + + match serde_json::from_str::(data) { + Ok(parsed) => { + if let Some(content) = parsed + .get("choices") + .and_then(|c| c.get(0)) + .and_then(|c| c.get("delta")) + .and_then(|d| d.get("content")) + .and_then(|v| v.as_str()) + { + if !content.is_empty() { + // Print delta chunk immediately for live streaming effect + print!("{content}"); + let _ = std::io::stdout().flush(); + accumulated.push_str(content); + } + } + } + Err(e) => { + debug!("skipping unparseable SSE chunk: {e}"); + } + } + } + } else { + line_buf.push(ch); + } + } + } + + // Process any remaining partial line + if !line_buf.trim().is_empty() { + let line = line_buf.trim(); + if let Some(data) = line.strip_prefix("data: ") { + if data.trim() != "[DONE]" { + if let Ok(parsed) = serde_json::from_str::(data) { + if let Some(content) = parsed + .get("choices") + .and_then(|c| c.get(0)) + .and_then(|c| c.get("delta")) + .and_then(|d| d.get("content")) + .and_then(|v| v.as_str()) + { + if !content.is_empty() { + print!("{content}"); + let _ = std::io::stdout().flush(); + accumulated.push_str(content); + } + } + } + } + } + } + + debug!(accumulated_len = accumulated.len(), "streaming complete"); + Ok(accumulated) +} + /// Scan a batch of file contents using the LLM. Returns issues found. +#[allow(clippy::format_push_string)] pub async fn scan_files( llm_config: &LLMConfig, files_content: &str, focus: &[String], rules: &[String], -) -> Result<(Vec, Option, Option)> { + response_format: &str, + system_prompt_override: Option<&str>, +) -> std::result::Result<(Vec, Option, Option), CoraError> { let spinner = create_spinner("Scanning filesโ€ฆ"); + let system_prompt = system_prompt_override.unwrap_or(SCAN_SYSTEM_PROMPT); + let mut user_prompt = String::new(); if !focus.is_empty() { user_prompt.push_str(&format!("Focus areas: {}\n\n", focus.join(", "))); @@ -219,21 +523,100 @@ pub async fn scan_files( if !rules.is_empty() { user_prompt.push_str(&format!( "Additional rules:\n{}\n\n", - rules.iter().map(|r| format!("- {}", r)).collect::>().join("\n") + rules + .iter() + .map(|r| format!("- {r}")) + .collect::>() + .join("\n") )); } user_prompt.push_str("Files to review:\n\n"); user_prompt.push_str(files_content); - let raw = chat_completion(llm_config, SCAN_SYSTEM_PROMPT, &user_prompt, Some(&spinner)).await?; + let raw = chat_completion( + llm_config, + system_prompt, + &user_prompt, + Some(&spinner), + response_format, + ) + .await?; parse_scan_response(&raw) } +/// Extract file paths from a unified diff string. +/// Matches lines like `--- a/path/file.rs` and `+++ b/path/file.rs`. +pub(crate) fn extract_file_paths_from_diff(diff: &str) -> Vec { + let mut paths = std::collections::HashSet::new(); + for line in diff.lines() { + let trimmed = line.trim_start(); + // Match unified diff headers: `--- a/path` or `+++ b/path` + // Also handles `--- path` without a/ or b/ prefix (some diffs) + let (prefix, strip_ab) = if let Some(rest) = trimmed.strip_prefix("--- a/") { + (rest, true) + } else if let Some(rest) = trimmed.strip_prefix("+++ b/") { + (rest, true) + } else if let Some(rest) = trimmed.strip_prefix("--- ") { + (rest, false) + } else if let Some(rest) = trimmed.strip_prefix("+++ ") { + (rest, false) + } else { + continue; + }; + // Skip /dev/null (binary files, deletes) + if prefix.starts_with("/dev/null") { + continue; + } + let path = if strip_ab { + prefix.to_string() + } else { + // Strip a/ or b/ prefix if present + prefix + .strip_prefix("a/") + .or_else(|| prefix.strip_prefix("b/")) + .unwrap_or(prefix) + .to_string() + }; + // Strip trailing \t (git shows tabs for renamed files) + let path = path.split('\t').next().unwrap_or(&path); + if !path.is_empty() { + paths.insert(path.to_string()); + } + } + paths.into_iter().collect() +} + /// Build the user prompt for diff review. -fn build_review_prompt(diff: &str, focus: &[String], rules: &[String]) -> String { +#[allow(clippy::format_push_string)] +fn build_review_prompt( + diff: &str, + focus: &[String], + rules: &[String], + static_context: Option<&str>, +) -> String { let mut prompt = String::new(); + // Inject valid file paths to reduce hallucination + let file_paths = extract_file_paths_from_diff(diff); + if !file_paths.is_empty() { + prompt.push_str("Valid files in this diff:\n"); + for path in &file_paths { + prompt.push_str(&format!("- \"{path}\"\n")); + } + prompt.push('\n'); + } + + // Inject static analysis context (clippy output, etc.) + if let Some(ctx) = static_context { + if !ctx.is_empty() { + prompt.push_str("Static analysis context (pre-verified by compiler/linter):\n"); + prompt.push_str("---\n"); + prompt.push_str(ctx); + prompt.push_str("\n---\n\n"); + } + } + if !focus.is_empty() { prompt.push_str(&format!("Focus areas: {}\n\n", focus.join(", "))); } @@ -254,32 +637,44 @@ fn build_review_prompt(diff: &str, focus: &[String], rules: &[String]) -> String } /// Parse the LLM response into review issues. -/// Handles: raw JSON array, JSON wrapped in ```json fences, array|||summary format. -fn parse_review_response( +/// Handles: raw JSON array, JSON wrapped in markdown fences, array and summary format. +#[allow(clippy::type_complexity)] +pub(crate) fn parse_review_response( raw: &str, -) -> Result<(Vec, String, Option)> { +) -> std::result::Result<(Vec, String, Option), CoraError> { let (json_str, summary) = extract_json_and_summary(raw); // Strip markdown code fences if present let json_str = strip_code_fences(&json_str); - let issues: Vec = serde_json::from_str(&json_str) - .context("LLM response is not valid JSON array of issues")?; + // Repair common LLM JSON mistakes before strict parse + let json_str = repair_json_string(&json_str); + + let issues: Vec = + serde_json::from_str(&json_str).map_err(|e| CoraError::LlmParse(e.to_string()))?; Ok((issues, summary, None)) } /// Parse the LLM response for scan mode. -fn parse_scan_response( +#[allow(clippy::type_complexity)] +pub(crate) fn parse_scan_response( raw: &str, -) -> Result<(Vec, Option, Option)> { +) -> std::result::Result<(Vec, Option, Option), CoraError> { let (json_str, summary) = extract_json_and_summary(raw); let json_str = strip_code_fences(&json_str); - let issues: Vec = serde_json::from_str(&json_str) - .context("LLM scan response is not valid JSON array of issues")?; + // Repair common LLM JSON mistakes before strict parse + let json_str = repair_json_string(&json_str); - let summary = if summary.is_empty() { None } else { Some(summary) }; + let issues: Vec = + serde_json::from_str(&json_str).map_err(|e| CoraError::LlmParse(e.to_string()))?; + + let summary = if summary.is_empty() { + None + } else { + Some(summary) + }; Ok((issues, summary, None)) } @@ -320,6 +715,114 @@ fn extract_json_and_summary(raw: &str) -> (String, String) { } } +/// Repair common LLM JSON mistakes before strict parse. +/// +/// LLMs sometimes produce JSON with invalid escape sequences (e.g. lone backslashes +/// like `\s` or trailing `\` inside string values). This function applies minimal +/// fixes so `serde_json` can parse the output. +fn repair_json_string(json_str: &str) -> String { + // Replace lone backslashes inside JSON string values that aren't valid JSON escapes. + // Valid JSON escapes: \" \\ \/ \b \f \n \r \t \uXXXX + let repaired = repair_invalid_escapes(json_str); + if repaired == json_str { + json_str.to_string() + } else { + debug!("applied backslash repair to LLM JSON output"); + repaired + } +} + +/// Replace invalid escape sequences in JSON string values. +/// Tracks whether we're inside a string literal using a proper state machine +/// that handles escaped quotes correctly. +fn repair_invalid_escapes(input: &str) -> String { + let mut output = String::with_capacity(input.len()); + let mut chars = input.chars().peekable(); + + while let Some(c) = chars.next() { + match c { + '"' => { + output.push(c); + // Scan through string literal + loop { + match chars.next() { + Some('\\') => { + // Escape character โ€” check what follows + match chars.peek() { + Some(&next) if is_valid_json_escape(next) => { + output.push('\\'); + output.push(next); + chars.next(); // consume + if next == 'u' { + // Consume exactly 4 hex digits + let mut hex_count = 0; + for _ in 0..4 { + if let Some(&hex) = chars.peek() { + if hex.is_ascii_hexdigit() { + output.push(hex); + chars.next(); + hex_count += 1; + } + } + } + if hex_count < 4 { + // Invalid \u escape โ€” not enough hex digits + // Remove the \u we already output and repair + output.truncate(output.len() - 2); + output.push_str("\\\\u"); + // Re-peek remaining chars that weren't consumed + for _ in 0..(4 - hex_count) { + if let Some(&c) = chars.peek() { + output.push(c); + chars.next(); + } + } + } + } + } + Some(&next) => { + // Invalid escape โ€” double the backslash + debug!( + escape_seq = format!("\\{}", next), + "repairing invalid JSON escape" + ); + output.push_str("\\\\"); + output.push(next); + chars.next(); // consume + } + None => { + // Trailing backslash at end of input + output.push_str("\\\\"); + } + } + } + Some('"') => { + output.push('"'); + break; // end of string + } + Some(ch) => { + output.push(ch); + } + None => { + break; // EOF inside string โ€” let serde_json report it + } + } + } + } + _ => { + output.push(c); + } + } + } + + output +} + +/// Check if a character is a valid JSON escape sequence starter. +fn is_valid_json_escape(c: char) -> bool { + matches!(c, '"' | '\\' | '/' | 'b' | 'f' | 'n' | 'r' | 't' | 'u') +} + /// Strip ```json / ``` code fences from the response. fn strip_code_fences(s: &str) -> String { let trimmed = s.trim(); @@ -327,10 +830,379 @@ fn strip_code_fences(s: &str) -> String { .strip_prefix("```json") .or_else(|| trimmed.strip_prefix("```")) { - stripped.strip_suffix("```").unwrap_or(stripped).trim().to_string() + stripped + .strip_suffix("```") + .unwrap_or(stripped) + .trim() + .to_string() } else { trimmed.to_string() } } +#[cfg(test)] +mod tests { + use super::*; + use crate::engine::types::Severity; + + const SINGLE_ISSUE_JSON: &str = r#"[{"file":"src/main.rs","line":42,"severity":"critical","issue_type":"security","title":"SQL Injection","body":"User input is concatenated directly into SQL query.","suggested_fix":"Use parameterized queries."}]"#; + + const TWO_ISSUES_JSON: &str = r#"[ + {"file":"src/api.rs","line":10,"severity":"major","issue_type":"performance","title":"N+1 Query","body":"Query inside a loop.","suggested_fix":"Use eager loading."}, + {"file":"src/lib.rs","line":5,"severity":"minor","issue_type":"bugs","title":"Off-by-one","body":"Loop bound is off by one."} +]"#; + + const EMPTY_ARRAY: &str = "[]"; + + // โ”€โ”€โ”€ extract_json_and_summary โ”€โ”€โ”€ + + #[test] + fn extract_json_no_separator() { + let (json, summary) = extract_json_and_summary(SINGLE_ISSUE_JSON); + assert!(json.starts_with('[')); + assert!(summary.is_empty()); + } + + #[test] + fn extract_json_with_separator() { + let input = format!("{SINGLE_ISSUE_JSON}|||Found 1 critical issue."); + let (json, summary) = extract_json_and_summary(&input); + assert!(json.starts_with('[')); + assert_eq!(summary, "Found 1 critical issue."); + } + + #[test] + fn extract_json_with_separator_and_whitespace() { + let input = format!(" {SINGLE_ISSUE_JSON} ||| Some summary text "); + let (json, summary) = extract_json_and_summary(&input); + assert!(json.starts_with('[')); + assert_eq!(summary, "Some summary text"); + } + + #[test] + fn extract_json_finds_array_boundaries() { + // Text after the array but before the ||| + let input = format!("{SINGLE_ISSUE_JSON}\nHere is some trailing text."); + let (json, summary) = extract_json_and_summary(&input); + assert!(json.starts_with('[') && json.ends_with(']')); + assert_eq!(summary, "Here is some trailing text."); + } + + #[test] + fn extract_json_empty_separator() { + let (json, summary) = extract_json_and_summary("[]|||"); + assert_eq!(json, "[]"); + assert_eq!(summary, ""); + } + + // โ”€โ”€โ”€ strip_code_fences โ”€โ”€โ”€ + + #[test] + fn strip_fences_json() { + let fenced = "```json\n[{\"a\":1}]\n```"; + assert_eq!(strip_code_fences(fenced), "[{\"a\":1}]"); + } + + #[test] + fn strip_fences_plain() { + let fenced = "```\n[{\"a\":1}]\n```"; + assert_eq!(strip_code_fences(fenced), "[{\"a\":1}]"); + } + + #[test] + fn strip_fences_none() { + assert_eq!(strip_code_fences("[{\"a\":1}]"), "[{\"a\":1}]"); + } + + #[test] + fn strip_fences_unclosed() { + let fenced = "```json\n[{\"a\":1}]"; + assert_eq!(strip_code_fences(fenced), "[{\"a\":1}]"); + } + + // โ”€โ”€โ”€ parse_review_response โ”€โ”€โ”€ + + #[test] + fn parse_review_clean_json() { + let result = parse_review_response(SINGLE_ISSUE_JSON).unwrap(); + assert_eq!(result.0.len(), 1); + assert_eq!(result.0[0].file, "src/main.rs"); + assert_eq!(result.0[0].line, Some(42)); + assert_eq!(result.0[0].severity, Severity::Critical); + assert_eq!(result.1, ""); // no summary + } + + #[test] + fn parse_review_with_fences() { + let input = format!("```json\n{SINGLE_ISSUE_JSON}\n```"); + let result = parse_review_response(&input).unwrap(); + assert_eq!(result.0.len(), 1); + assert_eq!(result.0[0].severity, Severity::Critical); + } + + #[test] + fn parse_review_with_pipe_summary() { + let input = format!("{SINGLE_ISSUE_JSON}|||1 critical security vulnerability found."); + let result = parse_review_response(&input).unwrap(); + assert_eq!(result.0.len(), 1); + assert_eq!(result.1, "1 critical security vulnerability found."); + } + + #[test] + fn parse_review_empty_array() { + let result = parse_review_response(EMPTY_ARRAY).unwrap(); + assert!(result.0.is_empty()); + } + + #[test] + fn parse_review_two_issues() { + let result = parse_review_response(TWO_ISSUES_JSON).unwrap(); + assert_eq!(result.0.len(), 2); + assert_eq!(result.0[0].severity, Severity::Major); + assert_eq!(result.0[1].severity, Severity::Minor); + } + + #[test] + fn parse_review_malformed_json_errors() { + let result = parse_review_response("not json at all"); + assert!(result.is_err()); + } + + #[test] + fn parse_review_object_not_array_errors() { + let result = parse_review_response(r#"{"file":"x"}"#); + assert!(result.is_err()); + } + + #[test] + fn parse_review_json_with_trailing_text() { + // The parser should handle trailing text after the array + let input = format!("{SINGLE_ISSUE_JSON}\nSome extra text"); + let result = parse_review_response(&input).unwrap(); + assert_eq!(result.0.len(), 1); + assert_eq!(result.0[0].file, "src/main.rs"); + } + + // โ”€โ”€โ”€ parse_scan_response โ”€โ”€โ”€ + + #[test] + fn parse_scan_clean_json() { + let result = parse_scan_response(SINGLE_ISSUE_JSON).unwrap(); + assert_eq!(result.0.len(), 1); + assert!(result.1.is_none()); // no summary โ†’ None + } + + #[test] + fn parse_scan_with_pipe_summary() { + let input = format!("{EMPTY_ARRAY}|||No issues found."); + let result = parse_scan_response(&input).unwrap(); + assert!(result.0.is_empty()); + assert_eq!(result.1.as_deref(), Some("No issues found.")); + } + + #[test] + fn parse_scan_empty_no_summary() { + let result = parse_scan_response(EMPTY_ARRAY).unwrap(); + assert!(result.0.is_empty()); + assert!(result.1.is_none()); + } + + #[test] + fn parse_scan_with_fences() { + let input = format!("```json\n{SINGLE_ISSUE_JSON}\n```"); + let result = parse_scan_response(&input).unwrap(); + assert_eq!(result.0.len(), 1); + } + + #[test] + fn parse_scan_malformed_json_errors() { + assert!(parse_scan_response("{{invalid").is_err()); + } + + // โ”€โ”€โ”€ Various severity values โ”€โ”€โ”€ + + #[test] + fn parse_all_severities() { + let input = r#"[ + {"file":"a.rs","line":1,"severity":"critical","issue_type":"security","title":"T1","body":"B1"}, + {"file":"b.rs","line":2,"severity":"major","issue_type":"performance","title":"T2","body":"B2"}, + {"file":"c.rs","line":3,"severity":"minor","issue_type":"bugs","title":"T3","body":"B3"}, + {"file":"d.rs","line":4,"severity":"info","issue_type":"style","title":"T4","body":"B4"} + ]"#; + let result = parse_review_response(input).unwrap(); + assert_eq!(result.0.len(), 4); + assert_eq!(result.0[0].severity, Severity::Critical); + assert_eq!(result.0[1].severity, Severity::Major); + assert_eq!(result.0[2].severity, Severity::Minor); + assert_eq!(result.0[3].severity, Severity::Info); + } + + // โ”€โ”€โ”€ Various issue_type values โ”€โ”€โ”€ + + #[test] + fn parse_various_issue_types() { + let input = r#"[ + {"file":"a.rs","line":1,"severity":"critical","issue_type":"security","title":"T","body":"B"}, + {"file":"b.rs","line":2,"severity":"major","issue_type":"performance","title":"T","body":"B"}, + {"file":"c.rs","line":3,"severity":"minor","issue_type":"bugs","title":"T","body":"B"}, + {"file":"d.rs","line":4,"severity":"info","issue_type":"best_practice","title":"T","body":"B"}, + {"file":"e.rs","line":5,"severity":"info","issue_type":"style","title":"T","body":"B"} + ]"#; + let result = parse_review_response(input).unwrap(); + assert_eq!(result.0.len(), 5); + assert_eq!(result.0[0].issue_type.as_deref(), Some("security")); + assert_eq!(result.0[1].issue_type.as_deref(), Some("performance")); + assert_eq!(result.0[2].issue_type.as_deref(), Some("bugs")); + assert_eq!(result.0[3].issue_type.as_deref(), Some("best_practice")); + assert_eq!(result.0[4].issue_type.as_deref(), Some("style")); + } + + // โ”€โ”€โ”€ null/optional fields โ”€โ”€โ”€ + + #[test] + fn parse_issue_with_null_line() { + let input = r#"[{"file":"a.rs","line":null,"severity":"info","title":"T","body":"B"}]"#; + let result = parse_review_response(input).unwrap(); + assert_eq!(result.0[0].line, None); + } + + #[test] + fn parse_issue_with_null_suggested_fix() { + let input = r#"[{"file":"a.rs","line":1,"severity":"info","title":"T","body":"B","suggested_fix":null}]"#; + let result = parse_review_response(input).unwrap(); + assert!(result.0[0].suggested_fix.is_none()); + } + + #[test] + fn parse_issue_with_type_alias() { + // "type" should also work via serde alias + let input = r#"[{"file":"a.rs","line":1,"severity":"info","type":"security","title":"T","body":"B"}]"#; + let result = parse_review_response(input).unwrap(); + assert_eq!(result.0[0].issue_type.as_deref(), Some("security")); + } + + // โ”€โ”€โ”€ build_review_prompt โ”€โ”€โ”€ + + #[test] + fn build_prompt_basic() { + let prompt = build_review_prompt("diff content", &[], &[], None); + assert!(prompt.contains("diff content")); + assert!(prompt.contains("```diff")); + } + #[test] + fn build_prompt_with_focus() { + let prompt = build_review_prompt("d", &["security".to_string()], &[], None); + assert!(prompt.contains("Focus areas: security")); + } + + #[test] + fn build_prompt_with_rules() { + let prompt = build_review_prompt("d", &[], &["no unwrap".to_string()], None); + assert!(prompt.contains("no unwrap")); + } + + #[test] + fn build_prompt_contains_file_paths() { + let diff = "diff --git a/src/main.rs b/src/main.rs\n--- a/src/main.rs\n+++ b/src/main.rs\n@@ -1 +1 @@\n- old\n+ new"; + let prompt = build_review_prompt(diff, &[], &[], None); + assert!(prompt.contains("Valid files in this diff:")); + assert!(prompt.contains("src/main.rs")); + } + + #[test] + fn build_prompt_no_file_paths_for_empty_diff() { + let prompt = build_review_prompt("no diff headers here", &[], &[], None); + assert!(!prompt.contains("Valid files in this diff:")); + } + + // โ”€โ”€โ”€ extract_file_paths_from_diff โ”€โ”€โ”€ + + #[test] + fn extract_paths_single_file() { + let diff = "--- a/src/main.rs\n+++ b/src/main.rs\n@@ -1 +1 @@\n- old\n+ new"; + let paths = extract_file_paths_from_diff(diff); + assert_eq!(paths, vec!["src/main.rs"]); + } + + #[test] + fn extract_paths_multiple_files() { + let diff = "--- a/src/a.rs\n+++ b/src/a.rs\n--- a/src/b.rs\n+++ b/src/b.rs"; + let paths = extract_file_paths_from_diff(diff); + assert!(paths.contains(&"src/a.rs".to_string())); + assert!(paths.contains(&"src/b.rs".to_string())); + } + + #[test] + fn extract_paths_skips_dev_null() { + let diff = "--- /dev/null\n+++ b/src/new.rs\n--- a/src/old.rs\n+++ /dev/null"; + let paths = extract_file_paths_from_diff(diff); + assert!(paths.contains(&"src/new.rs".to_string())); + assert!(paths.contains(&"src/old.rs".to_string())); + } + + #[test] + fn extract_paths_deduplicates() { + let diff = "--- a/src/main.rs\n+++ b/src/main.rs\n--- a/src/main.rs\n+++ b/src/main.rs"; + let paths = extract_file_paths_from_diff(diff); + assert_eq!(paths.len(), 1); + } + + // โ”€โ”€โ”€ repair_json_string โ”€โ”€โ”€ + + #[test] + fn repair_valid_json_unchanged() { + let input = r#"[{"file":"a.rs","body":"use std::io;\nlet x = 1;"}]"#; + assert_eq!(repair_json_string(input), input); + } + + #[test] + fn repair_invalid_backslash_in_string() { + // LLM produced `\s` inside a JSON string โ€” should become `\\s` + let input = r#"[{"file":"a.rs","body":"regex: \s+"}]"#; + let repaired = repair_json_string(input); + // After repair, serde_json should parse it + let parsed: serde_json::Value = serde_json::from_str(&repaired).unwrap(); + assert_eq!(parsed[0]["body"], "regex: \\s+"); + } + + #[test] + fn repair_trailing_backslash_before_close() { + // LLM produced `\` right before end of string value โ€” the `\` followed by + // the closing `"` looks like escaped quote, but the actual LLM mistake is + // different. Test a realistic case: `\s` inside body text. + // This tests the most common LLM error: non-standard escape like \s + let input = r#"[{"file":"a.rs","body":"regex: \s+\d*","severity":"info","title":"T","issue_type":"style"}]"#; + let repaired = repair_json_string(input); + let parsed: serde_json::Value = serde_json::from_str(&repaired).unwrap(); + assert_eq!(parsed[0]["body"], "regex: \\s+\\d*"); + } + + #[test] + fn repair_preserves_valid_escapes() { + // Valid escapes should not be double-escaped + let input = r#"[{"file":"a.rs","body":"line1\nline2\ttab"}]"#; + let repaired = repair_json_string(input); + assert_eq!(repaired, input); + let parsed: serde_json::Value = serde_json::from_str(&repaired).unwrap(); + assert_eq!(parsed[0]["body"], "line1\nline2\ttab"); + } + + #[test] + fn repair_invalid_unicode_escape() { + // \u followed by non-hex โ€” should be escaped + let input = r#"[{"file":"a.rs","body":"\uGGGG"}]"#; + let repaired = repair_json_string(input); + let parsed: serde_json::Value = serde_json::from_str(&repaired).unwrap(); + assert_eq!(parsed[0]["body"], "\\uGGGG"); + } + + #[test] + fn parse_response_with_invalid_escapes() { + // End-to-end: LLM response with invalid escapes should parse successfully + let raw = r#"[{"file":"src/main.rs","line":10,"severity":"critical","issue_type":"security","title":"SQL Injection","body":"query ends with \n no wait \\","suggested_fix":"Use params"}]"#; + let result = parse_review_response(raw).unwrap(); + assert_eq!(result.0.len(), 1); + assert_eq!(result.0[0].title, "SQL Injection"); + } +} diff --git a/src/engine/mod.rs b/src/engine/mod.rs index d93e79d9..d1161453 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -1,6 +1,12 @@ +pub mod bundling; +pub mod cache; +pub mod context; +pub mod diff_parser; pub mod llm; pub mod review; +pub mod rules; pub mod scanner; +pub mod static_analysis; pub mod types; // Re-export commonly used types from other modules for convenience diff --git a/src/engine/review.rs b/src/engine/review.rs index f4e25976..152ac08d 100644 --- a/src/engine/review.rs +++ b/src/engine/review.rs @@ -1,22 +1,82 @@ -use anyhow::Result; +use crate::error::CoraError; use tracing::{debug, instrument}; use crate::config::schema::Config; use crate::engine::llm; -use crate::engine::types::{LLMConfig, ReviewIssue, ReviewResponse, ScanResponse}; +use crate::engine::types::{LLMConfig, ReviewIssue, ReviewResponse}; -/// Run a code review on the given diff string. +/// Load a custom system prompt from a file path. +/// Returns the file content, or None if the file doesn't exist, can't be read, +/// or is outside the project root (path traversal guard). +fn load_system_prompt_file(path: &str) -> Option { + let Ok(canonical) = std::fs::canonicalize(path) else { + tracing::debug!(path = path, "system_prompt_file does not exist"); + return None; + }; + let project_root = std::env::current_dir().ok()?; + let project_root = std::fs::canonicalize(&project_root).ok()?; + + if !canonical.starts_with(&project_root) { + tracing::warn!( + path = path, + "system_prompt_file is outside project root, ignoring (potential path traversal)" + ); + return None; + } + + match std::fs::read_to_string(&canonical) { + Ok(content) => Some(content), + Err(e) => { + tracing::warn!( + path = path, + error = %e, + "failed to read system_prompt_file, using default prompt" + ); + None + } + } +} + +/// Resolve the effective system prompt: inline override > file override > None (use default). +pub fn resolve_system_prompt(inline: Option<&str>, file_path: Option<&str>) -> Option { + if let Some(prompt) = inline { + Some(prompt.to_string()) + } else if let Some(path) = file_path { + load_system_prompt_file(path) + } else { + None + } +} + +/// Run a code review on the given diff string with optional streaming and cache control. /// -/// Builds the prompt from the diff + config focus/rules, calls the LLM, -/// parses the response, and sets `should_block` based on the config's -/// `hook.min_severity` threshold. +/// When `stream` is true, LLM tokens are printed to stdout in real-time. +/// When `use_cache` is false, the cache is bypassed. #[instrument(skip_all)] -pub async fn review_diff( +pub async fn review_diff_with_cache( + config: &Config, + llm_config: &LLMConfig, + diff: &str, + stream: bool, + use_cache: bool, + quiet: bool, +) -> std::result::Result { + review_diff_inner(config, llm_config, diff, stream, use_cache, quiet).await +} + +async fn review_diff_inner( config: &Config, llm_config: &LLMConfig, diff: &str, -) -> Result { - debug!(diff_len = diff.len(), "starting diff review"); + stream: bool, + use_cache: bool, + quiet: bool, +) -> std::result::Result { + debug!( + diff_len = diff.len(), + stream = stream, + "starting diff review" + ); if diff.trim().is_empty() { return Ok(ReviewResponse { @@ -27,23 +87,157 @@ pub async fn review_diff( }); } - let mut response = llm::review_diff( - llm_config, - diff, - &config.focus, - &config.rules, - ) - .await?; + // Check cache before calling LLM + if use_cache { + if let Some(cached) = crate::engine::cache::get_cached_review( + diff, + &llm_config.model, + llm_config.temperature, + config.cache_ttl, + ) { + debug!("returning cached review response"); + return Ok(cached); + } + } + + // Extract valid file paths for post-parse filtering + let valid_files = llm::extract_file_paths_from_diff(diff); + + // Resolve custom system prompt for review + let review_prompt = resolve_system_prompt( + config.review_system_prompt_override.as_deref(), + config.review_system_prompt_file.as_deref(), + ); + + // Collect static analysis context (clippy output, etc.) + let static_context = + crate::engine::static_analysis::collect_static_context(diff, &config.static_analysis); + + // Parse diff and run rule engine + let diff_chunks = crate::engine::diff_parser::parse_diff(diff); + let rule_findings = crate::engine::rules::run_rules(&diff_chunks, &config.rules_config); + let rule_context = crate::engine::rules::format_rule_context(&rule_findings); + // Keep a clone for merging after LLM (rule_findings may be consumed in error fallback) + let rule_findings_clone = rule_findings.clone(); + + // Combine static analysis + rule engine context for LLM prompt + let combined_context = match (static_context.as_deref(), rule_context.as_str()) { + (Some(sa), rc) if !rc.is_empty() => Some(format!("{sa}\n\n{rc}")), + (Some(sa), _) => Some(sa.to_string()), + (_, rc) if !rc.is_empty() => Some(rc.to_string()), + _ => None, + }; + + // Build context chain (cross-file dependency extraction) + let context_chain = crate::engine::context::build_context_chain( + &diff_chunks, + &config.context_chain, + std::env::current_dir().unwrap_or_default().as_path(), + &config.ignore.rules, + ); + + let final_context = if !context_chain.text.is_empty() { + match combined_context { + Some(ctx) => Some(format!( + "{ctx}\n\n## Cross-file Context\n{context_chain_text}", + context_chain_text = context_chain.text + )), + None => Some(format!("## Cross-file Context\n{}", context_chain.text)), + } + } else { + combined_context + }; + + // Call LLM โ€” but preserve deterministic rule findings even on LLM failure + let llm_result: Result = if stream { + llm::review_diff_stream( + llm_config, + diff, + &config.focus, + &config.rules, + &config.response_format, + review_prompt.as_deref(), + final_context.as_deref(), + ) + .await + } else { + llm::review_diff( + llm_config, + diff, + &config.focus, + &config.rules, + &config.response_format, + review_prompt.as_deref(), + quiet, + final_context.as_deref(), + ) + .await + }; + + let mut response = match llm_result { + Ok(resp) => resp, + Err(e) => { + // LLM failed โ€” return deterministic findings only (don't silently swallow them) + if !rule_findings.is_empty() { + let n_rules = rule_findings.len(); + debug!( + error = %e, + rule_findings = n_rules, + "LLM call failed, returning deterministic rule findings only" + ); + let mut fallback = ReviewResponse { + issues: crate::engine::rules::merge_rule_findings(vec![], rule_findings), + summary: format!( + "LLM review failed: {e}. Showing {n_rules} deterministic rule findings only." + ), + tokens_used: None, + should_block: false, + }; + fallback.issues = apply_ignore_rules(fallback.issues, &config.ignore.rules); + let min_sev = config.hook.min_severity_level(); + fallback.should_block = fallback + .issues + .iter() + .any(|issue| issue.severity <= min_sev); + return Ok(fallback); + } + return Err(e); + } + }; + + // Merge rule findings with LLM issues (rule findings appended after LLM issues) + if !rule_findings_clone.is_empty() { + response.issues = + crate::engine::rules::merge_rule_findings(response.issues, rule_findings_clone); + } + + // Filter out issues with invalid file paths (hallucination guard) + if !valid_files.is_empty() { + let before = response.issues.len(); + response + .issues + .retain(|issue| is_valid_file_path(&issue.file, &valid_files)); + let filtered = before - response.issues.len(); + if filtered > 0 { + debug!( + filtered, + remaining = response.issues.len(), + "filtered issues with invalid file paths" + ); + } + } // Apply ignore rules: filter out issues matching ignored patterns response.issues = apply_ignore_rules(response.issues, &config.ignore.rules); // Calculate should_block based on min_severity let min_severity = config.hook.min_severity_level(); + // Ord order is Critical(0) < Major(1) < Minor(2) < Info(3), so "at or above + // min_severity" means Ord value <= min_severity. response.should_block = response .issues .iter() - .any(|issue| issue.severity >= min_severity); + .any(|issue| issue.severity <= min_severity); debug!( issues = response.issues.len(), @@ -51,61 +245,22 @@ pub async fn review_diff( "review complete" ); - Ok(response) -} - -/// Scan a full project or set of files. -/// -/// Walks the directory, filters files, batches them, calls the LLM, -/// and aggregates results. -#[instrument(skip_all)] -pub async fn scan_project( - config: &Config, - llm_config: &LLMConfig, - files_content: &str, - files_count: usize, - lines_count: usize, -) -> Result { - debug!(files = files_count, lines = lines_count, "starting project scan"); - - if files_content.trim().is_empty() { - return Ok(ScanResponse { - issues: vec![], - files_scanned: 0, - lines_scanned: 0, - summary: "No files to scan.".to_string(), - tokens_used: None, - should_block: false, - }); + // Save fully-processed response to cache (after filtering) + if use_cache { + if let Err(e) = crate::engine::cache::save_cached_review( + diff, + &llm_config.model, + llm_config.temperature, + &response, + ) { + debug!("failed to save review to cache: {}", e); + } } - let (issues, summary, tokens_used) = llm::scan_files( - llm_config, - files_content, - &config.focus, - &config.rules, - ) - .await?; - - // Apply ignore rules - let issues = apply_ignore_rules(issues, &config.ignore.rules); - - // Calculate should_block - let min_severity = config.hook.min_severity_level(); - let should_block = issues.iter().any(|issue| issue.severity >= min_severity); - - let default_summary = format!("Scanned {} files, found {} issues.", files_count, issues.len()); - Ok(ScanResponse { - issues, - files_scanned: files_count, - lines_scanned: lines_count, - summary: summary.unwrap_or(default_summary), - tokens_used, - should_block, - }) + Ok(response) } -/// Filter out issues whose issue_type matches any ignored rule pattern. +/// Filter out issues whose `issue_type` matches any ignored rule pattern. fn apply_ignore_rules(mut issues: Vec, ignore_rules: &[String]) -> Vec { if ignore_rules.is_empty() { return issues; @@ -114,11 +269,7 @@ fn apply_ignore_rules(mut issues: Vec, ignore_rules: &[String]) -> issues.retain(|issue| { !ignore_rules.iter().any(|pattern| { let pattern_lower = pattern.to_lowercase(); - let issue_type_lower = issue.issue_type - .as_ref() - .map(|t| t.to_string()) - .unwrap_or_default() - .to_lowercase(); + let issue_type_lower = issue.issue_type.clone().unwrap_or_default().to_lowercase(); issue_type_lower.contains(&pattern_lower) || issue.title.to_lowercase().contains(&pattern_lower) }) @@ -126,3 +277,52 @@ fn apply_ignore_rules(mut issues: Vec, ignore_rules: &[String]) -> issues } + +/// Check if a file path from an LLM issue matches any of the valid diff file paths. +/// Uses exact match only โ€” the LLM should report paths exactly as they appear in the diff. +fn is_valid_file_path(issue_file: &str, valid_files: &[String]) -> bool { + valid_files.iter().any(|f| f == issue_file) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolve_prompt_inline_takes_priority() { + let result = resolve_system_prompt(Some("inline prompt"), Some("file.md")); + assert_eq!(result.as_deref(), Some("inline prompt")); + } + + #[test] + fn resolve_prompt_file_fallback() { + // Use a file within the project root so the path traversal guard allows it + let test_file = std::path::PathBuf::from(".cora-test-prompt.tmp"); + std::fs::write(&test_file, "file prompt content").unwrap(); + let result = resolve_system_prompt(None, Some(".cora-test-prompt.tmp")); + assert_eq!(result.as_deref(), Some("file prompt content")); + let _ = std::fs::remove_file(&test_file); + } + + #[test] + fn resolve_prompt_none_when_both_missing() { + let result = resolve_system_prompt(None, None); + assert!(result.is_none()); + } + + #[test] + fn resolve_prompt_none_when_file_missing() { + let result = resolve_system_prompt(None, Some("/nonexistent/prompt.md")); + assert!(result.is_none()); + } + + #[test] + fn reject_path_traversal_outside_project() { + // /etc/passwd exists but is outside project root โ€” should be rejected + let result = resolve_system_prompt(None, Some("/etc/passwd")); + assert!( + result.is_none(), + "system_prompt_file outside project root should be rejected" + ); + } +} diff --git a/src/engine/rules/builtin.rs b/src/engine/rules/builtin.rs new file mode 100644 index 00000000..12b2f32b --- /dev/null +++ b/src/engine/rules/builtin.rs @@ -0,0 +1,127 @@ +/// Built-in rules for the rule engine. +use crate::engine::Severity; +use crate::engine::rules::types::CustomRule; + +/// Returns the full list of built-in rules. +pub fn builtin_rules() -> Vec { + vec![ + // --- Security --- + CustomRule { + id: "sec-hardcoded-secret".to_string(), + pattern: r#"(?i)(?:password|api_?key|token|secret)\s*=\s*"[^"]+""# + .to_string(), + severity: Severity::Critical, + message: "Possible hardcoded secret/credential detected. Use environment variables or a secrets manager.".to_string(), + languages: vec!["all".to_string()], + exclude: vec![], + }, + CustomRule { + id: "sec-sql-concat".to_string(), + pattern: r##"format!\("SELECT|f"SELECT|f"INSERT|f"UPDATE|f"DELETE|query\s*\+="## + .to_string(), + severity: Severity::Critical, + message: "Possible SQL injection via string concatenation in query. Use parameterized queries.".to_string(), + languages: vec!["all".to_string()], + exclude: vec![], + }, + CustomRule { + id: "sec-hardcoded-url".to_string(), + pattern: r"http://[a-zA-Z0-9][\w.\-]+(:\d+)?(/\S*)?" + .to_string(), + severity: Severity::Major, + message: "Insecure HTTP URL detected (not https). Use HTTPS for all external connections.".to_string(), + languages: vec!["all".to_string()], + exclude: vec![], + }, + CustomRule { + id: "sec-tls-disabled".to_string(), + pattern: r"tls_built_in_root_certs\(false\)|verify\s*=\s*False|InsecureRequestWarning|ACCEPT_INVALID_CERTS|dangerAcceptAnyServerCert".to_string(), + severity: Severity::Critical, + message: "TLS verification disabled. This allows man-in-the-middle attacks.".to_string(), + languages: vec!["rs".to_string(), "py".to_string(), "go".to_string()], + exclude: vec![], + }, + // --- Bugs --- + CustomRule { + id: "bug-unwrap".to_string(), + pattern: r"\.unwrap\(\)".to_string(), + severity: Severity::Minor, + message: "Use of `.unwrap()` can panic in production. Handle the error properly.".to_string(), + languages: vec!["rs".to_string()], + exclude: vec!["tests/".to_string(), "test/".to_string()], + }, + CustomRule { + id: "bug-expect".to_string(), + pattern: r#"\.expect\(""# + .to_string(), + severity: Severity::Minor, + message: "Use of `.expect()` can panic in production. Consider proper error handling.".to_string(), + languages: vec!["rs".to_string()], + exclude: vec!["tests/".to_string(), "test/".to_string()], + }, + CustomRule { + id: "bug-println".to_string(), + pattern: r"(?:println!|dbg!|print!\s*\()".to_string(), + severity: Severity::Minor, + message: "Debug output macro found. Remove `println!`/`dbg!`/`print!` before merging.".to_string(), + languages: vec!["rs".to_string()], + exclude: vec!["tests/".to_string(), "test/".to_string()], + }, + CustomRule { + id: "bug-todo".to_string(), + pattern: r"(?i)\b(?:TODO|FIXME|HACK|XXX)\b".to_string(), + severity: Severity::Info, + message: "TODO/FIXME/HACK/XXX comment found. Consider resolving before merge.".to_string(), + languages: vec!["all".to_string()], + exclude: vec![], + }, + CustomRule { + id: "bug-console-log".to_string(), + pattern: r"console\.(?:log|debug|info)\s*\(".to_string(), + severity: Severity::Minor, + message: "Console logging statement found. Remove before merging to production.".to_string(), + languages: vec!["js".to_string(), "ts".to_string()], + exclude: vec!["tests/".to_string(), "test/".to_string()], + }, + CustomRule { + id: "bug-hardcoded-port".to_string(), + pattern: r#"(?::"8080"|:"3000"|:"5000")"#.to_string(), + severity: Severity::Info, + message: "Hardcoded port number detected. Consider using environment variables or config.".to_string(), + languages: vec!["all".to_string()], + exclude: vec![], + }, + // --- Quality --- + CustomRule { + id: "qual-error-ignore".to_string(), + pattern: r"let\s+_\s*=\s*\w+::\w+\s*\(".to_string(), + severity: Severity::Minor, + message: "Error result discarded with `let _ =`. Consider handling the error explicitly.".to_string(), + languages: vec!["all".to_string()], + exclude: vec![], + }, + CustomRule { + id: "qual-clone".to_string(), + pattern: r"\.clone\(\)".to_string(), + severity: Severity::Info, + message: "Use of `.clone()` detected. Consider borrowing or ownership transfer.".to_string(), + languages: vec!["rs".to_string()], + exclude: vec![], + }, + ] +} + +/// Post-match filter for rules that need additional validation after regex match. +/// Returns `true` to suppress a finding that the regex matched but should be ignored. +pub fn post_match_filter(rule_id: &str, line: &str) -> bool { + match rule_id { + "sec-hardcoded-url" => { + let lower = line.to_lowercase(); + lower.contains("http://localhost") + || lower.contains("http://127.0.0.1") + || lower.contains("http://0.0.0.0") + || lower.contains("http://[::1]") + } + _ => false, + } +} diff --git a/src/engine/rules/matching.rs b/src/engine/rules/matching.rs new file mode 100644 index 00000000..ad8d9aea --- /dev/null +++ b/src/engine/rules/matching.rs @@ -0,0 +1,193 @@ +/// Matching engine for rules against diff lines. +use tracing::debug; + +use super::types::CustomRule; + +/// Check if a rule applies to the given file language. +pub fn matches_language(rule: &CustomRule, file_lang: &str) -> bool { + rule.languages.iter().any(|lang| { + let lang_lower = lang.to_lowercase(); + lang_lower == "all" || lang_lower == file_lang + }) +} + +/// Check if a file path should be excluded from a rule based on glob patterns. +pub fn matches_exclude(rule: &CustomRule, file_path: &str) -> bool { + // Also check built-in default exclude paths + let all_excludes: Vec<&str> = rule + .exclude + .iter() + .map(String::as_str) + .chain(DEFAULT_EXCLUDE_PATHS.iter().copied()) + .collect(); + + for pattern in &all_excludes { + // Simple glob matching: treat * as any chars, support directory prefixes + if glob_matches(pattern, file_path) { + return true; + } + } + + false +} + +/// Check if a rule's regex pattern matches a line of code. +pub fn match_rule_against_line(rule: &CustomRule, line: &str) -> bool { + match regex::Regex::new(&rule.pattern) { + Ok(re) => re.is_match(line), + Err(e) => { + debug!(rule_id = %rule.id, error = %e, "invalid regex in rule, skipping"); + false + } + } +} + +/// Simple glob matching: supports `*` wildcard and directory prefix checks. +fn glob_matches(pattern: &str, path: &str) -> bool { + // Handle "*_test.*" style patterns + if pattern.starts_with('*') && pattern.contains('.') { + // e.g., "*_test.*" โ€” match files ending with "_test.something" + let suffix = &pattern[1..]; // "_test.*" + if let Some(dot_idx) = suffix.find('.') { + let name_part = &suffix[..dot_idx]; // "_test" + let ext_part = &suffix[dot_idx..]; // ".*" + let file_name = path.rsplit('/').next().unwrap_or(path); + if file_name.ends_with(name_part) { + let remaining = &file_name[file_name.len() - name_part.len()..]; + if remaining == name_part { + // Check extension + if ext_part == ".*" { + return true; + } + if let Some(file_ext) = file_name.rsplit_once(name_part).map(|(_, ext)| ext) { + if file_ext.starts_with('.') + && ext_part + .strip_prefix('.') + .is_some_and(|e| file_ext.ends_with(e)) + { + return true; + } + } + } + } + } + } + + // Handle "dir/" prefix patterns (directory-based exclusion) + if pattern.ends_with('/') { + return path.starts_with(pattern) || path.contains(&pattern.to_string()); + } + + // Handle "**" glob in patterns like "tests/**" + if pattern.contains("**") { + let prefix = pattern.split("**").next().unwrap_or(""); + return path.starts_with(prefix); + } + + // Handle simple substring patterns + if pattern.starts_with('*') && pattern.ends_with('*') { + let inner = &pattern[1..pattern.len() - 1]; + return path.contains(inner); + } + + // Exact or prefix match + path == pattern || path.starts_with(&format!("{pattern}/")) +} + +/// Default paths to exclude from rule matching (test directories, fixtures, etc.). +const DEFAULT_EXCLUDE_PATHS: &[&str] = &[ + "tests/", + "test/", + "__tests__/", + "spec/", + "fixtures/", + "examples/", + // File patterns handled in glob_matches: *_test.*, *_spec.* +]; + +#[cfg(test)] +mod tests { + use super::*; + use crate::engine::Severity; + + fn make_rule(pattern: &str, languages: &[&str], exclude: &[&str]) -> CustomRule { + CustomRule { + id: "test-rule".to_string(), + pattern: pattern.to_string(), + severity: Severity::Minor, + message: "test".to_string(), + languages: languages.iter().map(|s| s.to_string()).collect(), + exclude: exclude.iter().map(|s| s.to_string()).collect(), + } + } + + #[test] + fn matches_language_all() { + let rule = make_rule("test", &["all"], &[]); + assert!(matches_language(&rule, "rs")); + assert!(matches_language(&rule, "py")); + assert!(matches_language(&rule, "js")); + } + + #[test] + fn matches_language_specific() { + let rule = make_rule("test", &["rs"], &[]); + assert!(matches_language(&rule, "rs")); + assert!(!matches_language(&rule, "py")); + } + + #[test] + fn matches_language_case_insensitive() { + let rule = make_rule("test", &["RS"], &[]); + assert!(matches_language(&rule, "rs")); + } + + #[test] + fn matches_exclude_test_dir() { + let rule = make_rule("test", &["all"], &[]); + assert!(matches_exclude(&rule, "tests/main.rs")); + assert!(matches_exclude(&rule, "test/helper.py")); + } + + #[test] + fn not_excluded_src_dir() { + let rule = make_rule("test", &["all"], &[]); + assert!(!matches_exclude(&rule, "src/main.rs")); + } + + #[test] + fn matches_exclude_custom() { + let rule = make_rule("test", &["all"], &["vendor/"]); + assert!(matches_exclude(&rule, "vendor/lib.rs")); + } + + #[test] + fn match_rule_against_line_simple() { + let rule = make_rule(r"println!", &["all"], &[]); + assert!(match_rule_against_line(&rule, "println!(\"hello\")")); + assert!(!match_rule_against_line(&rule, "let x = 1;")); + } + + #[test] + fn match_rule_invalid_regex_returns_false() { + let rule = make_rule(r"(?P Vec { + if !config.enabled { + debug!("rule engine is disabled"); + return Vec::new(); + } + + let mut all_rules = builtin::builtin_rules(); + all_rules.extend(config.custom_rules.clone()); + + debug!( + rule_count = all_rules.len(), + "running rules against diff chunks" + ); + + let mut findings = Vec::new(); + + for file in chunks { + let file_path = file + .new_path + .as_deref() + .or(file.old_path.as_deref()) + .unwrap_or("unknown"); + + for hunk in &file.chunks { + for line in &hunk.lines { + // Only check added lines (new code being introduced) + if line.line_type != DiffLineType::Add { + continue; + } + + for rule in &all_rules { + // Check language filter + if !matching::matches_language(rule, &file.language) { + continue; + } + + // Check exclude filter + if matching::matches_exclude(rule, file_path) { + continue; + } + + // Check pattern match + if !matching::match_rule_against_line(rule, &line.content) { + continue; + } + + // Post-match filter (e.g., allow localhost URLs) + if builtin::post_match_filter(&rule.id, &line.content) { + continue; + } + + let line_no = line.new_line_no.unwrap_or(0); + + findings.push(RuleFinding { + rule_id: rule.id.clone(), + file: file_path.to_string(), + line: line_no, + severity: rule.severity, + title: format!("[{}] Rule: {}", rule.id, rule.id), + body: rule.message.clone(), + }); + } + } + } + } + + // Sort by severity (Critical first) then by file + line + findings.sort_by(|a, b| { + a.severity + .cmp(&b.severity) + .then_with(|| a.file.cmp(&b.file)) + .then_with(|| a.line.cmp(&b.line)) + }); + + // Deduplicate: same rule_id + file + line + findings.dedup_by(|a, b| a.rule_id == b.rule_id && a.file == b.file && a.line == b.line); + + // Cap at max_findings + let capped = findings.len().min(config.max_findings); + if findings.len() > capped { + debug!(total = findings.len(), capped, "capping rule findings"); + findings.truncate(capped); + } + + debug!(findings = findings.len(), "rule engine complete"); + findings +} + +/// Merge rule-based findings with LLM-produced issues into a single list. +/// +/// Rule findings are appended after LLM issues (LLM issues take priority). +/// Duplicates (same file + line) from rules are skipped if the LLM already +/// reported an issue for that location. +pub fn merge_rule_findings( + llm_issues: Vec, + rule_findings: Vec, +) -> Vec { + let mut result = llm_issues; + + // Build a set of (file, line) pairs from LLM issues to avoid duplicates + let llm_locations: std::collections::HashSet<(String, u32)> = result + .iter() + .filter_map(|issue| issue.line.map(|ln| (issue.file.clone(), ln))) + .collect(); + + for finding in rule_findings { + // Skip if LLM already has an issue at the same file+line + if llm_locations.contains(&(finding.file.clone(), finding.line)) { + debug!( + rule_id = %finding.rule_id, + file = %finding.file, + line = finding.line, + "skipping rule finding (LLM already reported issue at this location)" + ); + continue; + } + + result.push(ReviewIssue { + file: finding.file, + line: Some(finding.line), + severity: finding.severity, + issue_type: Some("rule".to_string()), + title: finding.title, + body: finding.body, + suggested_fix: None, + }); + } + + result +} + +/// Format rule findings as a context string for injection into the LLM prompt. +pub fn format_rule_context(findings: &[RuleFinding]) -> String { + if findings.is_empty() { + return String::new(); + } + + let mut ctx = String::from("Static rule engine findings (pre-verified):\n"); + ctx.push_str("---\n"); + + for f in findings { + ctx.push_str(&format!( + "- [{}] {}:{} โ€” {} ({}): {}", + f.severity, f.file, f.line, f.rule_id, f.severity, f.body + )); + ctx.push('\n'); + } + + ctx.push_str("---\n"); + ctx +} + +/// Convenience: parse diff + run rules in one step. +#[allow(dead_code)] // public API for future standalone usage +pub fn parse_and_run_rules(diff: &str, config: &RulesConfig) -> Vec { + let chunks = parse_diff(diff); + run_rules(&chunks, config) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::engine::Severity; + + fn default_config() -> RulesConfig { + RulesConfig { + enabled: true, + max_findings: 10, + custom_rules: Vec::new(), + } + } + + #[test] + fn unwrap_rule_fires_on_rust_code() { + let diff = r#"diff --git a/src/main.rs b/src/main.rs +--- a/src/main.rs ++++ b/src/main.rs +@@ -1,2 +1,3 @@ + fn main() { ++ let x = something.unwrap(); + } +"#; + let findings = parse_and_run_rules(diff, &default_config()); + let unwrap_findings: Vec<_> = findings + .iter() + .filter(|f| f.rule_id == "bug-unwrap") + .collect(); + assert!( + !unwrap_findings.is_empty(), + "should detect .unwrap() in added Rust code" + ); + } + + #[test] + fn todo_rule_fires() { + let diff = r#"diff --git a/src/main.rs b/src/main.rs +--- a/src/main.rs ++++ b/src/main.rs +@@ -1,2 +1,3 @@ + fn main() { ++ // TODO: fix this later + } +"#; + let findings = parse_and_run_rules(diff, &default_config()); + let todo_findings: Vec<_> = findings + .iter() + .filter(|f| f.rule_id == "bug-todo") + .collect(); + assert!(!todo_findings.is_empty(), "should detect TODO comment"); + } + + #[test] + fn println_rule_fires_in_rust() { + let diff = r#"diff --git a/src/lib.rs b/src/lib.rs +--- a/src/lib.rs ++++ b/src/lib.rs +@@ -1,1 +1,2 @@ + pub fn greet() { ++ println!("hello"); + } +"#; + let findings = parse_and_run_rules(diff, &default_config()); + assert!( + findings.iter().any(|f| f.rule_id == "bug-println"), + "should detect println! macro" + ); + } + + #[test] + fn console_log_rule_fires_in_ts() { + let diff = r#"diff --git a/app.ts b/app.ts +--- a/app.ts ++++ b/app.ts +@@ -1,1 +1,2 @@ + function init() { ++ console.log("starting"); + } +"#; + let findings = parse_and_run_rules(diff, &default_config()); + assert!( + findings.iter().any(|f| f.rule_id == "bug-console-log"), + "should detect console.log in TypeScript" + ); + } + + #[test] + fn language_filter_works() { + // println! in a Python file should NOT fire bug-println (Rust only) + let diff = r#"diff --git a/script.py b/script.py +--- a/script.py ++++ b/script.py +@@ -1,1 +1,2 @@ + def main(): ++ println!("hello") +"#; + let findings = parse_and_run_rules(diff, &default_config()); + assert!( + !findings.iter().any(|f| f.rule_id == "bug-println"), + "bug-println should not fire on Python files" + ); + } + + #[test] + fn exclude_filter_works() { + // unwrap in test/ directory should NOT fire + let diff = r#"diff --git a/tests/integration.rs b/tests/integration.rs +--- a/tests/integration.rs ++++ b/tests/integration.rs +@@ -1,1 +1,2 @@ + #[test] ++fn test_something() { let _ = result.unwrap(); } +"#; + let findings = parse_and_run_rules(diff, &default_config()); + assert!( + !findings.iter().any(|f| f.rule_id == "bug-unwrap"), + "bug-unwrap should not fire in tests/ directory" + ); + } + + #[test] + fn disabled_config_returns_no_findings() { + let diff = r#"diff --git a/src/main.rs b/src/main.rs +--- a/src/main.rs ++++ b/src/main.rs +@@ -1,2 +1,3 @@ + fn main() { ++ let x = something.unwrap(); + } +"#; + let config = RulesConfig { + enabled: false, + ..default_config() + }; + let findings = parse_and_run_rules(diff, &config); + assert!( + findings.is_empty(), + "disabled config should produce no findings" + ); + } + + #[test] + fn max_findings_cap() { + let diff = r#"diff --git a/src/main.rs b/src/main.rs +--- a/src/main.rs ++++ b/src/main.rs +@@ -1,2 +1,8 @@ + fn main() { ++ let a = x.unwrap(); ++ let b = y.clone(); ++ let c = z.clone(); ++ println!("debug"); ++ // TODO: something ++ // FIXME: another + } +"#; + let config = RulesConfig { + max_findings: 2, + ..default_config() + }; + let findings = parse_and_run_rules(diff, &config); + assert!(findings.len() <= 2, "should cap findings at max_findings"); + } + + #[test] + fn merge_skips_duplicates() { + let llm = vec![ReviewIssue { + file: "src/main.rs".to_string(), + line: Some(5), + severity: Severity::Minor, + issue_type: Some("bug".to_string()), + title: "Unnecessary unwrap".to_string(), + body: "Use proper error handling".to_string(), + suggested_fix: None, + }]; + + let rules = vec![RuleFinding { + rule_id: "bug-unwrap".to_string(), + file: "src/main.rs".to_string(), + line: 5, + severity: Severity::Minor, + title: "[bug-unwrap] Rule: bug-unwrap".to_string(), + body: "Can panic".to_string(), + }]; + + let merged = merge_rule_findings(llm, rules); + // Should have 1 issue (LLM's), not 2 โ€” rule finding at same location skipped + assert_eq!(merged.len(), 1); + } + + #[test] + fn merge_appends_unique_rule_findings() { + let llm = vec![ReviewIssue { + file: "src/main.rs".to_string(), + line: Some(5), + severity: Severity::Minor, + issue_type: Some("bug".to_string()), + title: "Some issue".to_string(), + body: "Details".to_string(), + suggested_fix: None, + }]; + + let rules = vec![RuleFinding { + rule_id: "bug-todo".to_string(), + file: "src/main.rs".to_string(), + line: 10, + severity: Severity::Info, + title: "[bug-todo] Rule: bug-todo".to_string(), + body: "TODO found".to_string(), + }]; + + let merged = merge_rule_findings(llm, rules); + assert_eq!(merged.len(), 2); + assert_eq!(merged[1].issue_type.as_deref(), Some("rule")); + } + + #[test] + fn format_rule_context_non_empty() { + let findings = vec![RuleFinding { + rule_id: "bug-todo".to_string(), + file: "src/lib.rs".to_string(), + line: 42, + severity: Severity::Info, + title: "[bug-todo] Rule: bug-todo".to_string(), + body: "TODO comment found".to_string(), + }]; + let ctx = format_rule_context(&findings); + assert!(ctx.contains("bug-todo")); + assert!(ctx.contains("src/lib.rs:42")); + } + + #[test] + fn format_rule_context_empty() { + let ctx = format_rule_context(&[]); + assert!(ctx.is_empty()); + } + + #[test] + fn hardcoded_secret_rule_fires() { + let diff = r#"diff --git a/config.py b/config.py +--- a/config.py ++++ b/config.py +@@ -1,2 +1,3 @@ + # Config ++password = "super_secret_123" + DB_HOST = "localhost" +"#; + let findings = parse_and_run_rules(diff, &default_config()); + assert!( + findings.iter().any(|f| f.rule_id == "sec-hardcoded-secret"), + "should detect hardcoded password" + ); + } + + #[test] + fn hardcoded_url_rule_fires() { + let diff = r#"diff --git a/src/client.rs b/src/client.rs +--- a/src/client.rs ++++ b/src/client.rs +@@ -1,2 +1,3 @@ + fn get_url() -> &'static str { ++ "http://example.com/api/data" + } +"#; + let chunks = parse_diff(diff); + eprintln!("DEBUG: {} chunks parsed", chunks.len()); + for (i, c) in chunks.iter().enumerate() { + eprintln!( + "DEBUG chunk[{}]: lang={}, new_path={:?}, hunks={}", + i, + c.language, + c.new_path, + c.chunks.len() + ); + for (j, h) in c.chunks.iter().enumerate() { + eprintln!("DEBUG hunk[{}]: {} lines", j, h.lines.len()); + for l in &h.lines { + eprintln!("DEBUG line: {:?} {:?}", l.line_type, l.content); + } + } + } + let findings = parse_and_run_rules(diff, &default_config()); + eprintln!("DEBUG: {} findings total", findings.len()); + for f in &findings { + eprintln!("DEBUG finding: {:?}", f); + } + assert!( + findings.iter().any(|f| f.rule_id == "sec-hardcoded-url"), + "should detect non-localhost http:// URL" + ); + } + + #[test] + fn hardcoded_url_localhost_allowed() { + let diff = r#"diff --git a/src/client.rs b/src/client.rs +--- a/src/client.rs ++++ b/src/client.rs +@@ -1,2 +1,3 @@ + fn get_url() -> &'static str { ++ "http://localhost:3000/health" + } +"#; + let findings = parse_and_run_rules(diff, &default_config()); + assert!( + !findings.iter().any(|f| f.rule_id == "sec-hardcoded-url"), + "localhost http URLs should be allowed" + ); + } +} diff --git a/src/engine/rules/types.rs b/src/engine/rules/types.rs new file mode 100644 index 00000000..dae6d8d1 --- /dev/null +++ b/src/engine/rules/types.rs @@ -0,0 +1,59 @@ +/// Rule types used by the rule engine. +use serde::{Deserialize, Serialize}; + +use crate::engine::Severity; + +/// Runtime configuration for the rule engine (lives on `Config`). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RulesConfig { + /// Whether the rule engine is enabled. + pub enabled: bool, + /// Maximum number of findings to report per review (prevents noisy output). + pub max_findings: usize, + /// User-defined custom rules, merged with built-in rules. + pub custom_rules: Vec, +} + +impl Default for RulesConfig { + fn default() -> Self { + Self { + enabled: true, + max_findings: 5, + custom_rules: Vec::new(), + } + } +} + +/// A user-defined or built-in rule definition. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CustomRule { + /// Unique rule identifier (e.g., `"sec-hardcoded-secret"`). + pub id: String, + /// Regex pattern to match against diff lines. + pub pattern: String, + /// Severity of findings from this rule. + pub severity: Severity, + /// Human-readable description used as the finding body. + pub message: String, + /// Languages this rule applies to. `["all"]` means all languages. + pub languages: Vec, + /// Glob patterns for file paths to exclude from this rule. + pub exclude: Vec, +} + +/// A single finding produced by a rule. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RuleFinding { + /// The rule that produced this finding. + pub rule_id: String, + /// File path where the finding was located. + pub file: String, + /// Line number in the new file. + pub line: u32, + /// Severity of this finding. + pub severity: Severity, + /// Short title. + pub title: String, + /// Detailed description. + pub body: String, +} diff --git a/src/engine/scanner.rs b/src/engine/scanner.rs index 575f9d6b..ea0bc8cb 100644 --- a/src/engine/scanner.rs +++ b/src/engine/scanner.rs @@ -1,11 +1,11 @@ use std::collections::BTreeSet; use std::path::Path; -use anyhow::Result; +use crate::error::CoraError; use glob::Pattern; +use ignore::WalkBuilder; use indicatif::{ProgressBar, ProgressStyle}; use tracing::debug; -use walkdir::WalkDir; /// A file to be scanned, with its relative path and content. #[derive(Debug, Clone)] @@ -18,37 +18,35 @@ pub struct FileEntry { pub lines: usize, } -/// Maximum characters per batch to avoid exceeding LLM token limits. -const MAX_BATCH_CHARS: usize = 60_000; - -/// Maximum files per batch. -const MAX_FILES_PER_BATCH: usize = 20; - /// File extensions to include in scans by default (source code). const DEFAULT_EXTENSIONS: &[&str] = &[ - "rs", "py", "js", "ts", "tsx", "jsx", "go", "java", "kt", "rb", - "c", "cpp", "h", "hpp", "cs", "php", "swift", "scala", "vue", - "svelte", "sh", "bash", "zsh", "ps1", - "toml", "yaml", "yml", "json", "sql", "graphql", "proto", - "md", "rst", "txt", "html", "css", "scss", "less", + "rs", "py", "js", "ts", "tsx", "jsx", "go", "java", "kt", "rb", "c", "cpp", "h", "hpp", "cs", + "php", "swift", "scala", "vue", "svelte", "sh", "bash", "zsh", "ps1", "toml", "yaml", "yml", + "json", "sql", "graphql", "proto", "md", "rst", "txt", "html", "css", "scss", "less", ]; /// Walk a project directory, respecting .gitignore, and collect scannable files. /// /// Uses `include` and `exclude` glob patterns to filter results. +/// The `ignore` crate handles .gitignore (including nested), .git/info/exclude, +/// and hidden-file filtering automatically. +#[allow(clippy::unnecessary_wraps, clippy::format_push_string)] pub fn walk_project( root: &Path, include_patterns: &[String], exclude_patterns: &[String], extra_extensions: &[String], -) -> Result> { +) -> std::result::Result, CoraError> { debug!( root = %root.display(), "walking project directory" ); // Build extension set - let mut extensions: BTreeSet = DEFAULT_EXTENSIONS.iter().map(|s| s.to_string()).collect(); + let mut extensions: BTreeSet = DEFAULT_EXTENSIONS + .iter() + .map(std::string::ToString::to_string) + .collect(); for ext in extra_extensions { extensions.insert(ext.trim_start_matches('.').to_lowercase()); } @@ -63,9 +61,6 @@ pub fn walk_project( .filter_map(|p| Pattern::new(p).ok()) .collect(); - // Load .gitignore if present - let gitignore = load_gitignore(root); - let mut entries = Vec::new(); let spinner = ProgressBar::new_spinner(); @@ -77,17 +72,23 @@ pub fn walk_project( ); spinner.set_message("Scanning filesโ€ฆ"); - for entry in WalkDir::new(root) - .into_iter() - .filter_entry(|e| { - // Skip hidden files/directories - e.file_name() - .to_str() - .map(|s| !s.starts_with('.')) - .unwrap_or(true) - }) - .filter_map(|e| e.ok()) - { + let walker = WalkBuilder::new(root) + .hidden(true) // respect hidden files (skip dotfiles) + .git_ignore(true) // respect .gitignore at all levels + .git_global(true) // respect core.excludesFile + .git_exclude(true) // respect .git/info/exclude + .require_git(false) // apply gitignore rules even outside git repos + .build(); + + for result in walker { + let entry = match result { + Ok(e) => e, + Err(err) => { + debug!(error = %err, "error during directory walk"); + continue; + } + }; + let path = entry.path(); // Skip directories @@ -95,17 +96,13 @@ pub fn walk_project( continue; } + // Get relative path (entry.depth() ensures it's under root) let relative = path .strip_prefix(root) .unwrap_or(path) .to_string_lossy() .to_string(); - // Check gitignore - if is_ignored_by_gitignore(&relative, &gitignore) { - continue; - } - // Check exclude patterns if exclude_globs.iter().any(|g| g.matches(&relative)) { continue; @@ -129,7 +126,10 @@ pub fn walk_project( // Read file content (skip binary / unreadable) let content = match std::fs::read_to_string(path) { Ok(c) => c, - Err(_) => continue, + Err(e) => { + debug!(file = %relative, error = %e, "skipping unreadable file"); + continue; + } }; // Skip empty files @@ -185,6 +185,7 @@ pub fn batch_files(files: &[FileEntry], max_chars: usize, max_files: usize) -> V } /// Format a batch of files for the LLM prompt. +#[allow(clippy::format_push_string)] pub fn format_batch_for_prompt(files: &[FileEntry]) -> String { let mut output = String::new(); @@ -198,46 +199,3 @@ pub fn format_batch_for_prompt(files: &[FileEntry]) -> String { output } - -/// Load .gitignore patterns from a project root. -fn load_gitignore(root: &Path) -> Vec { - let gitignore_path = root.join(".gitignore"); - if !gitignore_path.is_file() { - return Vec::new(); - } - - std::fs::read_to_string(&gitignore_path) - .unwrap_or_default() - .lines() - .filter(|line| { - let trimmed = line.trim(); - !trimmed.is_empty() && !trimmed.starts_with('#') - }) - .map(|s| s.to_string()) - .collect() -} - -/// Simple check if a path matches any gitignore pattern. -fn is_ignored_by_gitignore(path: &str, patterns: &[String]) -> bool { - for pattern in patterns { - let p = pattern.trim(); - if p.is_empty() { - continue; - } - // Simple glob matching - if let Ok(glob) = Pattern::new(&format!("**/{p}")) { - if glob.matches(path) { - return true; - } - } - // Direct prefix match for directory patterns - if p.ends_with('/') && path.starts_with(p) { - return true; - } - // Exact file match - if path == p || path.ends_with(&format!("/{p}")) { - return true; - } - } - false -} diff --git a/src/engine/static_analysis.rs b/src/engine/static_analysis.rs new file mode 100644 index 00000000..7c8d5cc4 --- /dev/null +++ b/src/engine/static_analysis.rs @@ -0,0 +1,251 @@ +#![allow(clippy::question_mark, unused_mut)] + +/// Static analysis integration โ€” run clippy and extract relevant output. +/// +/// Used to inject compiler/linter context into the review prompt, +/// reducing false positives on verified-intentional changes. +use std::time::Duration; +use tracing::debug; + +use crate::config::schema::StaticAnalysisConfig; + +/// Maximum clippy output to inject (in characters). +/// Keeps prompt tokens reasonable โ€” clippy output for a focused diff +/// should rarely exceed this. +const MAX_CLIPPY_OUTPUT_CHARS: usize = 4000; + +/// Run static analysis and return formatted context string, or None. +/// +pub fn collect_static_context(diff: &str, config: &StaticAnalysisConfig) -> Option { + // Wrap in block_in_place โ€” called from async review pipeline + tokio::task::block_in_place(|| collect_static_context_inner(diff, config)) +} + +fn collect_static_context_inner(diff: &str, config: &StaticAnalysisConfig) -> Option { + if let Some(file_path) = &config.clippy_output_file { + return read_clippy_file(file_path); + } + + if config.auto_clippy { + return run_clippy_for_diff(diff); + } + + None +} + +/// Read clippy output from a pre-computed file with path traversal guard. +fn read_clippy_file(path: &str) -> Option { + let Ok(canonical) = std::fs::canonicalize(path) else { + debug!(path = path, "clippy output file does not exist"); + return None; + }; + + let project_root = std::env::current_dir().ok()?; + let project_root = std::fs::canonicalize(&project_root).ok()?; + + if !canonical.starts_with(&project_root) { + debug!( + path = path, + "clippy output file is outside project root, ignoring (path traversal guard)" + ); + return None; + } + + let content = std::fs::read_to_string(&canonical).ok()?; + let trimmed = content.trim(); + + if trimmed.is_empty() { + debug!("clippy output file is empty, skipping"); + return None; + } + + Some(trimmed.to_string()) +} + +/// Run `cargo clippy` and filter output to lines mentioning files in the diff. +/// Uses `block_in_place` to avoid starving the async runtime. +/// Has a 30-second timeout on clippy execution. +fn run_clippy_for_diff(diff: &str) -> Option { + let changed_files = extract_changed_rust_files(diff); + if changed_files.is_empty() { + debug!("no Rust files changed in diff, skipping clippy"); + return None; + } + + debug!( + files = changed_files.len(), + "running clippy for changed Rust files" + ); + + let result = tokio::task::block_in_place(|| { + let mut child = std::process::Command::new("cargo") + .args([ + "clippy", + "--message-format=short", + "--", + "-W", + "clippy::all", + ]) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn(); + + let child = match child { + Ok(c) => c, + Err(e) => { + debug!("clippy spawn failed: {}", e); + return None; + } + }; + + // Spawn thread to wait + collect output with 30-second timeout. + // Child is moved into thread โ€” on timeout we rely on the thread + // completing and the OS reaping the process. In practice clippy + // rarely hangs; the timeout is a safety net. + let (tx, rx) = std::sync::mpsc::channel(); + let child_pid = child.id(); + std::thread::spawn(move || match child.wait_with_output() { + Ok(output) => { + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let _ = tx.send((stdout, stderr)); + } + Err(e) => { + debug!("clippy wait_with_output failed: {}", e); + } + }); + + match rx.recv_timeout(Duration::from_secs(30)) { + Ok((stdout, stderr)) => Some((stdout, stderr)), + Err(_) => { + debug!(pid = child_pid, "clippy timed out after 30 seconds"); + None + } + } + }); + let (stdout, stderr) = match result { + Some(r) => r, + None => return None, + }; + + // Combine stdout + stderr, clippy sometimes outputs to stderr + let combined = format!("{}\n{}", stdout, stderr); + + // Filter to lines mentioning changed files + let mut relevant_lines = Vec::new(); + for line in combined.lines() { + let trimmed = line.trim(); + // Skip cargo build messages, empty lines, progress output + if trimmed.is_empty() + || trimmed.starts_with("Compiling") + || trimmed.starts_with("Downloading") + || trimmed.starts_with("Fresh") + || trimmed.starts_with("Finished") + || trimmed.starts_with("Updating") + || trimmed.starts_with("Locking") + { + continue; + } + + // Check if line mentions any changed file + if changed_files.iter().any(|f| line.contains(f.as_str())) { + relevant_lines.push(trimmed.to_string()); + } + } + + if relevant_lines.is_empty() { + debug!("no clippy warnings for changed files"); + return None; + } + + let result = relevant_lines.join("\n"); + + // Truncate at char boundary (safe for UTF-8) + if result.len() > MAX_CLIPPY_OUTPUT_CHARS { + let mut end = MAX_CLIPPY_OUTPUT_CHARS; + // floor_char_boundary is available on Rust 1.85+ + while !result.is_char_boundary(end) { + end -= 1; + } + debug!( + original_len = result.len(), + truncated_len = end, + "clippy output truncated" + ); + Some(format!("{}\n... (truncated)", &result[..end])) + } else { + Some(result) + } +} + +/// Extract changed Rust file paths from a diff. +/// Returns base paths like `src/engine/llm.rs` (no `a/` or `b/` prefix). +fn extract_changed_rust_files(diff: &str) -> Vec { + let mut files = Vec::new(); + + for line in diff.lines() { + let trimmed = line.trim(); + + // Match diff headers: --- a/path.rs or +++ b/path.rs + if let Some(path) = trimmed + .strip_prefix("--- a/") + .or_else(|| trimmed.strip_prefix("+++ b/")) + { + if path.ends_with(".rs") && !path.contains("target/") { + files.push(path.to_string()); + } + } + } + + files.sort(); + files.dedup(); + files +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extract_rust_files_from_diff() { + let diff = "--- a/src/main.rs\n+++ b/src/main.rs\n@@ -1,1 +1,2 @@\n use anyhow::Result;\n--- a/src/engine/llm.rs\n+++ b/src/engine/llm.rs\n@@ -10,1 +10,2 @@\n pub async fn review_diff()"; + + let files = extract_changed_rust_files(diff); + assert_eq!( + files, + vec!["src/engine/llm.rs".to_string(), "src/main.rs".to_string()] + ); + } + + #[test] + fn extract_skips_non_rust() { + let diff = "--- a/src/main.rs\n+++ b/src/main.rs\n--- a/package.json\n+++ b/package.json"; + + let files = extract_changed_rust_files(diff); + assert_eq!(files, vec!["src/main.rs".to_string()]); + } + + #[test] + fn extract_skips_target_dir() { + let diff = "--- a/target/debug/build.rs\n+++ b/target/debug/build.rs"; + let files = extract_changed_rust_files(diff); + assert!(files.is_empty()); + } + + #[test] + fn empty_config_returns_none() { + let config = StaticAnalysisConfig::default(); + let result = collect_static_context("some diff", &config); + assert!(result.is_none()); + } + + #[test] + fn empty_diff_returns_none_for_auto_clippy() { + let config = StaticAnalysisConfig { + auto_clippy: true, + clippy_output_file: None, + }; + let result = collect_static_context("", &config); + assert!(result.is_none()); + } +} diff --git a/src/engine/types.rs b/src/engine/types.rs index d791bbaa..acebb440 100644 --- a/src/engine/types.rs +++ b/src/engine/types.rs @@ -21,22 +21,9 @@ impl Severity { _ => Severity::Info, } } -} - -impl fmt::Display for Severity { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Severity::Critical => write!(f, "critical"), - Severity::Major => write!(f, "major"), - Severity::Minor => write!(f, "minor"), - Severity::Info => write!(f, "info"), - } - } -} -impl Severity { /// Get the label text for this severity. - pub fn label(&self) -> &'static str { + pub fn label(self) -> &'static str { match self { Severity::Critical => "CRITICAL", Severity::Major => "MAJOR", @@ -46,7 +33,7 @@ impl Severity { } /// Get the icon for this severity. - pub fn icon(&self) -> &'static str { + pub fn icon(self) -> &'static str { match self { Severity::Critical => "๐Ÿ”ด", Severity::Major => "๐ŸŸ ", @@ -56,8 +43,24 @@ impl Severity { } } -/// Issue type categories -#[derive(Debug, Clone, Serialize, Deserialize)] +impl fmt::Display for Severity { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Severity::Critical => write!(f, "critical"), + Severity::Major => write!(f, "major"), + Severity::Minor => write!(f, "minor"), + Severity::Info => write!(f, "info"), + } + } +} + +/// Issue type categories. +/// +/// Kept for API completeness โ€” used for deserialization and future typed +/// issue-type matching. Currently the LLM returns `issue_type` as a string, +/// but this enum provides a structured alternative. +#[allow(dead_code)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum IssueType { Security, @@ -68,6 +71,7 @@ pub enum IssueType { Suggestion, } +#[allow(dead_code)] impl fmt::Display for IssueType { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { @@ -81,6 +85,25 @@ impl fmt::Display for IssueType { } } +#[allow(dead_code)] +impl IssueType { + /// Parse from string with lenient matching (accepts plural forms, etc.) + pub fn from_str_lossy(s: &str) -> Self { + match s.to_lowercase().as_str() { + "security" | "sec" => IssueType::Security, + "performance" | "perf" => IssueType::Performance, + "bug" | "bugs" => IssueType::Bug, + "best_practice" | "best-practice" | "bestpractice" | "best practice" => { + IssueType::BestPractice + } + "style" | "formatting" => IssueType::Style, + // suggestion/info merged into wildcard โ€” all three arms returned + // identical IssueType::Suggestion, verified by clippy match_same_arms + _ => IssueType::Suggestion, + } + } +} + /// A single review issue found in code #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ReviewIssue { @@ -88,14 +111,280 @@ pub struct ReviewIssue { #[serde(default)] pub line: Option, pub severity: Severity, + /// Issue type/category โ€” stored as string since LLM output varies. + /// Common values: security, performance, bug, `best_practice`, style, suggestion #[serde(rename = "type", alias = "issue_type")] - pub issue_type: Option, + pub issue_type: Option, pub title: String, pub body: String, #[serde(default)] pub suggested_fix: Option, } +#[cfg(test)] +#[allow(clippy::float_cmp)] +mod tests { + use super::*; + + // โ”€โ”€โ”€ Severity::from_str_lossy โ”€โ”€โ”€ + + #[test] + fn severity_critical() { + assert_eq!(Severity::from_str_lossy("critical"), Severity::Critical); + } + + #[test] + fn severity_major() { + assert_eq!(Severity::from_str_lossy("major"), Severity::Major); + } + + #[test] + fn severity_minor() { + assert_eq!(Severity::from_str_lossy("minor"), Severity::Minor); + } + + #[test] + fn severity_info() { + assert_eq!(Severity::from_str_lossy("info"), Severity::Info); + } + + #[test] + fn severity_case_insensitive() { + assert_eq!(Severity::from_str_lossy("CRITICAL"), Severity::Critical); + assert_eq!(Severity::from_str_lossy("Major"), Severity::Major); + assert_eq!(Severity::from_str_lossy("MINOR"), Severity::Minor); + assert_eq!(Severity::from_str_lossy("Info"), Severity::Info); + } + + #[test] + fn severity_unknown_falls_back_to_info() { + assert_eq!(Severity::from_str_lossy("unknown"), Severity::Info); + assert_eq!(Severity::from_str_lossy(""), Severity::Info); + assert_eq!(Severity::from_str_lossy("foobar"), Severity::Info); + } + + // โ”€โ”€โ”€ Severity ordering โ”€โ”€โ”€ + + #[test] + fn severity_ordering() { + // Ord is by discriminant: Critical(0) < Major(1) < Minor(2) < Info(3) + assert!(Severity::Critical < Severity::Major); + assert!(Severity::Major < Severity::Minor); + assert!(Severity::Minor < Severity::Info); + } + + // โ”€โ”€โ”€ Severity display โ”€โ”€โ”€ + + #[test] + fn severity_display() { + assert_eq!(format!("{}", Severity::Critical), "critical"); + assert_eq!(format!("{}", Severity::Major), "major"); + assert_eq!(format!("{}", Severity::Minor), "minor"); + assert_eq!(format!("{}", Severity::Info), "info"); + } + + #[test] + fn severity_label() { + assert_eq!(Severity::Critical.label(), "CRITICAL"); + assert_eq!(Severity::Major.label(), "MAJOR"); + assert_eq!(Severity::Minor.label(), "MINOR"); + assert_eq!(Severity::Info.label(), "INFO"); + } + + #[test] + fn severity_icon() { + assert!(!Severity::Critical.icon().is_empty()); + assert!(!Severity::Major.icon().is_empty()); + assert!(!Severity::Minor.icon().is_empty()); + assert!(!Severity::Info.icon().is_empty()); + } + + // โ”€โ”€โ”€ IssueType::from_str_lossy โ”€โ”€โ”€ + + #[test] + fn issue_type_security() { + assert_eq!(IssueType::from_str_lossy("security"), IssueType::Security); + } + + #[test] + fn issue_type_sec_alias() { + assert_eq!(IssueType::from_str_lossy("sec"), IssueType::Security); + } + + #[test] + fn issue_type_performance() { + assert_eq!( + IssueType::from_str_lossy("performance"), + IssueType::Performance + ); + } + + #[test] + fn issue_type_perf_alias() { + assert_eq!(IssueType::from_str_lossy("perf"), IssueType::Performance); + } + + #[test] + fn issue_type_bug() { + assert_eq!(IssueType::from_str_lossy("bug"), IssueType::Bug); + } + + #[test] + fn issue_type_bugs_alias() { + assert_eq!(IssueType::from_str_lossy("bugs"), IssueType::Bug); + } + + #[test] + fn issue_type_best_practice_variants() { + assert_eq!( + IssueType::from_str_lossy("best_practice"), + IssueType::BestPractice + ); + assert_eq!( + IssueType::from_str_lossy("best-practice"), + IssueType::BestPractice + ); + assert_eq!( + IssueType::from_str_lossy("bestpractice"), + IssueType::BestPractice + ); + assert_eq!( + IssueType::from_str_lossy("best practice"), + IssueType::BestPractice + ); + } + + #[test] + fn issue_type_style() { + assert_eq!(IssueType::from_str_lossy("style"), IssueType::Style); + } + + #[test] + fn issue_type_formatting_alias() { + assert_eq!(IssueType::from_str_lossy("formatting"), IssueType::Style); + } + + #[test] + fn issue_type_suggestion() { + assert_eq!( + IssueType::from_str_lossy("suggestion"), + IssueType::Suggestion + ); + } + + #[test] + fn issue_type_info_alias() { + assert_eq!(IssueType::from_str_lossy("info"), IssueType::Suggestion); + } + + #[test] + fn issue_type_unknown_falls_back() { + assert_eq!(IssueType::from_str_lossy("xyz"), IssueType::Suggestion); + assert_eq!(IssueType::from_str_lossy(""), IssueType::Suggestion); + } + + // โ”€โ”€โ”€ IssueType display โ”€โ”€โ”€ + + #[test] + fn issue_type_display() { + assert_eq!(format!("{}", IssueType::Security), "security"); + assert_eq!(format!("{}", IssueType::Performance), "performance"); + assert_eq!(format!("{}", IssueType::Bug), "bug"); + assert_eq!(format!("{}", IssueType::BestPractice), "best_practice"); + assert_eq!(format!("{}", IssueType::Style), "style"); + assert_eq!(format!("{}", IssueType::Suggestion), "suggestion"); + } + + // โ”€โ”€โ”€ LLMConfig::default โ”€โ”€โ”€ + + #[test] + fn llm_config_default() { + let cfg = LLMConfig::default(); + assert!(cfg.api_key.is_empty()); + assert_eq!(cfg.base_url, "https://api.openai.com/v1"); + assert_eq!(cfg.model, "gpt-4o-mini"); + assert_eq!(cfg.provider, "openai"); + assert_eq!(cfg.temperature, 0.0); + assert_eq!(cfg.max_tokens, 4096); + assert_eq!(cfg.timeout, 120); + } + + // โ”€โ”€โ”€ TokenUsage::default โ”€โ”€โ”€ + + #[test] + fn token_usage_default() { + let usage = TokenUsage::default(); + assert_eq!(usage.input_tokens, 0); + assert_eq!(usage.output_tokens, 0); + assert!((usage.estimated_cost_usd - 0.0).abs() < f64::EPSILON); + } + + // โ”€โ”€โ”€ Exit codes โ”€โ”€โ”€ + + #[test] + fn exit_codes_are_correct() { + assert_eq!(EXIT_OK, 0); + assert_eq!(EXIT_ERROR, 1); + assert_eq!(EXIT_BLOCKED, 2); + assert_eq!(EXIT_AUTH_ERROR, 3); + } + + // โ”€โ”€โ”€ Constants โ”€โ”€โ”€ + + #[test] + fn max_diff_size() { + assert_eq!(MAX_DIFF_SIZE, 50 * 1024); + } + + #[test] + fn max_scan_batch_files() { + assert_eq!(MAX_SCAN_BATCH_FILES, 20); + } + + #[test] + fn max_scan_batch_chars() { + assert_eq!(MAX_SCAN_BATCH_CHARS, 80_000); + } + + // โ”€โ”€โ”€ ReviewIssue serde round-trip โ”€โ”€โ”€ + + #[test] + fn review_issue_roundtrip() { + let issue = ReviewIssue { + file: "src/main.rs".to_string(), + line: Some(42), + severity: Severity::Critical, + issue_type: Some("security".to_string()), + title: "SQL Injection".to_string(), + body: "Details here".to_string(), + suggested_fix: Some("Use params".to_string()), + }; + let json = serde_json::to_string(&issue).unwrap(); + let back: ReviewIssue = serde_json::from_str(&json).unwrap(); + assert_eq!(back.file, issue.file); + assert_eq!(back.line, issue.line); + assert_eq!(back.severity, issue.severity); + assert_eq!(back.issue_type, issue.issue_type); + assert_eq!(back.title, issue.title); + assert_eq!(back.body, issue.body); + assert_eq!(back.suggested_fix, issue.suggested_fix); + } + + #[test] + fn review_issue_with_type_alias_deserializes() { + let json = r#"{"file":"a.rs","severity":"info","type":"security","title":"T","body":"B"}"#; + let issue: ReviewIssue = serde_json::from_str(json).unwrap(); + assert_eq!(issue.issue_type.as_deref(), Some("security")); + } + + #[test] + fn review_issue_with_issue_type_deserializes() { + let json = r#"{"file":"a.rs","severity":"info","issue_type":"performance","title":"T","body":"B"}"#; + let issue: ReviewIssue = serde_json::from_str(json).unwrap(); + assert_eq!(issue.issue_type.as_deref(), Some("performance")); + } +} + /// Token usage tracking #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct TokenUsage { @@ -132,6 +421,9 @@ pub struct LLMConfig { pub base_url: String, pub model: String, pub provider: String, + pub temperature: f32, + pub max_tokens: u32, + pub timeout: u64, } impl Default for LLMConfig { @@ -141,21 +433,39 @@ impl Default for LLMConfig { base_url: "https://api.openai.com/v1".to_string(), model: "gpt-4o-mini".to_string(), provider: "openai".to_string(), + temperature: 0.0, + max_tokens: 4096, + timeout: 120, } } } -/// CLI exit codes +/// CLI exit codes. +/// +/// Kept for API completeness โ€” consumers and tests may reference these. +#[allow(dead_code)] pub const EXIT_OK: i32 = 0; +#[allow(dead_code)] pub const EXIT_ERROR: i32 = 1; +#[allow(dead_code)] pub const EXIT_BLOCKED: i32 = 2; +#[allow(dead_code)] pub const EXIT_AUTH_ERROR: i32 = 3; -/// Maximum diff size in bytes (50KB by default) +/// Maximum diff size in bytes (50KB by default). +/// +/// Kept for API completeness โ€” future commands may enforce this limit. +#[allow(dead_code)] pub const MAX_DIFF_SIZE: usize = 50 * 1024; -/// Maximum files per scan batch +/// Maximum files per scan batch. +/// +/// Kept for API completeness โ€” scanner uses matching values inline. +#[allow(dead_code)] pub const MAX_SCAN_BATCH_FILES: usize = 20; -/// Maximum characters per scan batch +/// Maximum characters per scan batch. +/// +/// Kept for API completeness โ€” scanner uses matching values inline. +#[allow(dead_code)] pub const MAX_SCAN_BATCH_CHARS: usize = 80_000; diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 00000000..19bf53f3 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,87 @@ +use thiserror::Error; + +/// Typed error enum for the cora engine layer. +/// +/// Engine functions return `Result`. The CLI/command layer +/// continues to use `anyhow::Result` and converts via `?` (automatic +/// `From for anyhow::Error` provided by `thiserror` + anyhow interop). +#[derive(Debug, Error)] +#[allow(dead_code)] +pub enum CoraError { + /// HTTP request to LLM API failed (network, DNS, timeout, etc.) + #[error("LLM API request failed: {0}")] + LlmRequest(#[from] reqwest::Error), + + /// LLM API returned a non-2xx status code. + #[error("LLM API returned status {status}: {body}")] + LlmStatus { status: u16, body: String }, + + /// Failed to parse LLM JSON response. + #[error("failed to parse LLM JSON response: {0}")] + LlmParse(String), + + /// Error during streaming. + #[error("streaming error: {0}")] + LlmStream(String), + + /// JSON repair still failed after fix attempt. + #[error("JSON repair failed: {0}")] + LlmJsonRepair(String), + + /// YAML config parse error. + #[error("config parse error: {0}")] + ConfigParse(String), + + /// Config file read error. + #[error("config read error: {0}")] + ConfigRead(String), + + /// No API key configured. + #[error("no API key found")] + NoApiKey, + + /// Diff exceeds maximum allowed size. + #[error("diff too large ({actual} chars, max {max})")] + DiffTooLarge { actual: usize, max: usize }, + + /// Invalid git ref (shell metacharacters or path traversal). + #[error("invalid ref: {0}")] + InvalidRef(String), + + /// Git command execution failed. + #[error("git command `{command}` failed: {stderr}")] + GitCommand { command: String, stderr: String }, + + /// Not inside a git repository. + #[error("not inside a git repository")] + NotInGitRepo, + + /// HEAD is detached (cannot determine branch name). + #[error("HEAD is detached โ€” cannot determine branch name")] + HeadDetached, + + /// Cache I/O error (read/write filesystem operations). + #[error("cache I/O error: {0}")] + CacheIo(#[from] std::io::Error), + + /// JSON serialization of cache failed. + #[error("cache serialize error: {0}")] + CacheSerialize(String), + + /// File scanner error. + #[error("scanner error: {0}")] + ScannerError(String), + + /// Auth file read/write error. + #[error("auth error: {0}")] + AuthError(String), + + /// Hook install/uninstall error. + #[error("hook error: {0}")] + HookError(String), + + /// LLM request timed out. + #[error("LLM request timed out: {0}")] + Timeout(String), +} +// test comment for cora review diff --git a/src/formatters/compact.rs b/src/formatters/compact.rs index d0bf8829..42a43a1e 100644 --- a/src/formatters/compact.rs +++ b/src/formatters/compact.rs @@ -7,6 +7,7 @@ use crate::formatters::Formatter; /// Compact formatter: one line per issue. Ideal for git hooks. pub struct CompactFormatter; +#[allow(clippy::format_push_string)] impl Formatter for CompactFormatter { fn format_review(&self, response: &ReviewResponse) -> Result { let mut output = String::new(); @@ -26,6 +27,12 @@ impl Formatter for CompactFormatter { output.push_str(&format_issue_compact(issue)); } + // Footer watermark (only when issues found) + output.push_str(&format!( + "Reviewed by Cora v{}\n", + env!("CARGO_PKG_VERSION") + )); + Ok(output) } @@ -43,11 +50,19 @@ impl Formatter for CompactFormatter { output.push_str(&format_issue_compact(issue)); } + // Footer watermark (only when issues found) + if !response.issues.is_empty() { + output.push_str(&format!( + "Reviewed by Cora v{}\n", + env!("CARGO_PKG_VERSION") + )); + } + Ok(output) } } -/// Format a single issue as one line: [SEVERITY] file:line: title +/// Format a single issue as one line: [SEVERITY] : title fn format_issue_compact(issue: &ReviewIssue) -> String { let sev = match issue.severity { Severity::Critical => "CRITICAL".red().bold().to_string(), @@ -63,3 +78,125 @@ fn format_issue_compact(issue: &ReviewIssue) -> String { format!("[{sev}] {loc}: {}\n", issue.title) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::engine::{ReviewIssue, ScanResponse, Severity}; + + fn sample_issue() -> ReviewIssue { + ReviewIssue { + file: "src/main.rs".to_string(), + line: Some(42), + severity: Severity::Critical, + issue_type: Some("security".to_string()), + title: "SQL Injection".to_string(), + body: "Details here.".to_string(), + suggested_fix: None, + } + } + + fn sample_response() -> ReviewResponse { + ReviewResponse { + issues: vec![sample_issue()], + summary: "Summary".to_string(), + tokens_used: None, + should_block: false, + } + } + + fn empty_response() -> ReviewResponse { + ReviewResponse { + issues: vec![], + summary: String::new(), + tokens_used: None, + should_block: false, + } + } + + fn sample_scan_response() -> ScanResponse { + ScanResponse { + issues: vec![sample_issue()], + summary: String::new(), + files_scanned: 10, + lines_scanned: 500, + tokens_used: None, + should_block: false, + } + } + + #[test] + fn format_review_contains_issue_count() { + let fmt = CompactFormatter; + let output = fmt.format_review(&sample_response()).unwrap(); + assert!(output.contains("Found 1 issue")); + } + + #[test] + fn format_review_contains_file() { + let fmt = CompactFormatter; + let output = fmt.format_review(&sample_response()).unwrap(); + assert!(output.contains("src/main.rs")); + } + + #[test] + fn format_review_contains_title() { + let fmt = CompactFormatter; + let output = fmt.format_review(&sample_response()).unwrap(); + assert!(output.contains("SQL Injection")); + } + + #[test] + fn format_review_empty_issues() { + let fmt = CompactFormatter; + let output = fmt.format_review(&empty_response()).unwrap(); + assert!(output.contains("No issues found")); + // No watermark when no issues + assert!(!output.contains("Reviewed by Cora")); + } + + #[test] + fn format_review_with_issues_has_watermark() { + let fmt = CompactFormatter; + let output = fmt.format_review(&sample_response()).unwrap(); + assert!(output.contains("Reviewed by Cora v")); + } + + #[test] + fn format_review_contains_severity() { + let fmt = CompactFormatter; + let output = fmt.format_review(&sample_response()).unwrap(); + assert!(output.contains("CRITICAL")); + } + + #[test] + fn format_scan_contains_file_count() { + let fmt = CompactFormatter; + let output = fmt.format_scan(&sample_scan_response()).unwrap(); + assert!(output.contains("10 files")); + } + + #[test] + fn format_issue_compact_no_line() { + let issue = ReviewIssue { + file: "README.md".to_string(), + line: None, + severity: Severity::Info, + issue_type: None, + title: "Typo".to_string(), + body: String::new(), + suggested_fix: None, + }; + let line = format_issue_compact(&issue); + assert!(line.contains("README.md")); + // When no line, file is directly followed by ": title" not "file:N: title" + // Output is "[INFO] README.md: Typo\n" + assert!(!line.contains("README.md:1")); + } + + #[test] + fn format_issue_compact_with_line() { + let line = format_issue_compact(&sample_issue()); + assert!(line.contains("src/main.rs:42")); + } +} diff --git a/src/formatters/json_fmt.rs b/src/formatters/json_fmt.rs index 6abb6c81..2e689916 100644 --- a/src/formatters/json_fmt.rs +++ b/src/formatters/json_fmt.rs @@ -1,19 +1,162 @@ use anyhow::Result; +use serde_json::{Value, json}; use crate::engine::{ReviewResponse, ScanResponse}; use crate::formatters::Formatter; -/// JSON formatter: outputs raw JSON. +/// JSON formatter: outputs raw JSON with a watermark footer. pub struct JsonFormatter; impl Formatter for JsonFormatter { fn format_review(&self, response: &ReviewResponse) -> Result { - let json = serde_json::to_string_pretty(response)?; + let mut obj = serde_json::to_value(response)?; + add_watermark(&mut obj, &response.issues); + let json = serde_json::to_string_pretty(&obj)?; Ok(json) } fn format_scan(&self, response: &ScanResponse) -> Result { - let json = serde_json::to_string_pretty(response)?; + let mut obj = serde_json::to_value(response)?; + add_watermark_scan(&mut obj, &response.issues); + let json = serde_json::to_string_pretty(&obj)?; Ok(json) } } + +/// Add a `reviewed_by` watermark field to a JSON value when issues are present. +fn add_watermark(obj: &mut Value, issues: &[crate::engine::ReviewIssue]) { + if !issues.is_empty() { + if let Some(map) = obj.as_object_mut() { + map.insert( + "reviewed_by".to_string(), + json!({ + "tool": "cora", + "version": env!("CARGO_PKG_VERSION") + }), + ); + } + } +} + +/// Add a `reviewed_by` watermark field for scan responses. +fn add_watermark_scan(obj: &mut Value, issues: &[crate::engine::ReviewIssue]) { + add_watermark(obj, issues); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::engine::{ReviewIssue, Severity, TokenUsage}; + + fn sample_issue() -> ReviewIssue { + ReviewIssue { + file: "src/main.rs".to_string(), + line: Some(42), + severity: Severity::Critical, + issue_type: Some("security".to_string()), + title: "SQL Injection".to_string(), + body: "User input concatenated into query.".to_string(), + suggested_fix: Some("Use parameterized queries.".to_string()), + } + } + + fn sample_response() -> ReviewResponse { + ReviewResponse { + issues: vec![sample_issue()], + summary: "Found 1 critical issue.".to_string(), + tokens_used: Some(TokenUsage { + input_tokens: 100, + output_tokens: 200, + estimated_cost_usd: 0.005, + }), + should_block: false, + } + } + + fn empty_response() -> ReviewResponse { + ReviewResponse { + issues: vec![], + summary: String::new(), + tokens_used: None, + should_block: false, + } + } + + fn sample_scan_response() -> ScanResponse { + ScanResponse { + issues: vec![sample_issue()], + summary: "Scan complete.".to_string(), + files_scanned: 10, + lines_scanned: 500, + tokens_used: None, + should_block: false, + } + } + + #[test] + fn format_review_output_is_valid_json() { + let fmt = JsonFormatter; + let output = fmt.format_review(&sample_response()).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&output).unwrap(); + assert!(parsed.get("issues").is_some()); + assert!(parsed.get("summary").is_some()); + } + + #[test] + fn format_review_contains_issue_data() { + let fmt = JsonFormatter; + let output = fmt.format_review(&sample_response()).unwrap(); + assert!(output.contains("src/main.rs")); + assert!(output.contains("SQL Injection")); + assert!(output.contains("critical")); + } + + #[test] + fn format_review_empty_issues() { + let fmt = JsonFormatter; + let output = fmt.format_review(&empty_response()).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&output).unwrap(); + assert_eq!(parsed["issues"].as_array().unwrap().len(), 0); + // No watermark when no issues + assert!(parsed.get("reviewed_by").is_none()); + } + + #[test] + fn format_review_with_issues_has_watermark() { + let fmt = JsonFormatter; + let output = fmt.format_review(&sample_response()).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&output).unwrap(); + // Watermark should be present when issues exist + let reviewed_by = &parsed["reviewed_by"]; + assert_eq!(reviewed_by["tool"].as_str().unwrap(), "cora"); + assert!(reviewed_by["version"].as_str().is_some()); + } + + #[test] + fn format_scan_output_is_valid_json() { + let fmt = JsonFormatter; + let output = fmt.format_scan(&sample_scan_response()).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&output).unwrap(); + assert!(parsed.get("files_scanned").is_some()); + assert!(parsed.get("lines_scanned").is_some()); + assert_eq!(parsed["files_scanned"].as_u64().unwrap(), 10); + } + + #[test] + fn format_review_multiple_issues() { + let mut response = sample_response(); + response.issues.push(ReviewIssue { + file: "src/lib.rs".to_string(), + line: Some(10), + severity: Severity::Minor, + issue_type: Some("style".to_string()), + title: "Naming".to_string(), + body: "Use snake_case.".to_string(), + suggested_fix: None, + }); + let fmt = JsonFormatter; + let output = fmt.format_review(&response).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&output).unwrap(); + assert_eq!(parsed["issues"].as_array().unwrap().len(), 2); + } +} diff --git a/src/formatters/mod.rs b/src/formatters/mod.rs index 13c30aba..df5f8c91 100644 --- a/src/formatters/mod.rs +++ b/src/formatters/mod.rs @@ -3,9 +3,9 @@ pub mod json_fmt; pub mod pretty; pub mod sarif; -use anyhow::Result; use crate::engine::ReviewResponse; use crate::engine::ScanResponse; +use anyhow::Result; /// Output format supported by cora. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -47,3 +47,68 @@ pub fn formatter_for(format: OutputFormat) -> Box { OutputFormat::Sarif => Box::new(sarif::SarifFormatter), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn output_format_pretty() { + assert_eq!( + OutputFormat::from_str_loose("pretty").unwrap(), + OutputFormat::Pretty + ); + } + + #[test] + fn output_format_json() { + assert_eq!( + OutputFormat::from_str_loose("json").unwrap(), + OutputFormat::Json + ); + } + + #[test] + fn output_format_compact() { + assert_eq!( + OutputFormat::from_str_loose("compact").unwrap(), + OutputFormat::Compact + ); + } + + #[test] + fn output_format_sarif() { + assert_eq!( + OutputFormat::from_str_loose("sarif").unwrap(), + OutputFormat::Sarif + ); + } + + #[test] + fn output_format_case_insensitive() { + assert_eq!( + OutputFormat::from_str_loose("JSON").unwrap(), + OutputFormat::Json + ); + assert_eq!( + OutputFormat::from_str_loose("Pretty").unwrap(), + OutputFormat::Pretty + ); + assert_eq!( + OutputFormat::from_str_loose("SARIF").unwrap(), + OutputFormat::Sarif + ); + } + + #[test] + fn output_format_unknown_errors() { + assert!(OutputFormat::from_str_loose("yaml").is_err()); + assert!(OutputFormat::from_str_loose("").is_err()); + } + + #[test] + fn formatter_for_returns_box() { + let fmt = formatter_for(OutputFormat::Json); + let _ = fmt; // just verify it compiles + } +} diff --git a/src/formatters/pretty.rs b/src/formatters/pretty.rs index 3e320d97..ccbfde9c 100644 --- a/src/formatters/pretty.rs +++ b/src/formatters/pretty.rs @@ -7,6 +7,7 @@ use crate::formatters::Formatter; /// Pretty formatter with colored terminal output and severity icons. pub struct PrettyFormatter; +#[allow(clippy::format_push_string)] impl Formatter for PrettyFormatter { fn format_review(&self, response: &ReviewResponse) -> Result { let mut output = String::new(); @@ -22,9 +23,7 @@ impl Formatter for PrettyFormatter { )); if response.issues.is_empty() { - output.push_str( - &"โœ… No issues found!".green().bold().to_string(), - ); + output.push_str(&"โœ… No issues found!".green().bold().to_string()); if !response.summary.is_empty() { output.push_str(&format!("\n\n{}", response.summary.dimmed())); } @@ -32,19 +31,35 @@ impl Formatter for PrettyFormatter { } // Summary line - let crit = response.issues.iter().filter(|i| i.severity == Severity::Critical).count(); - let maj = response.issues.iter().filter(|i| i.severity == Severity::Major).count(); - let min = response.issues.iter().filter(|i| i.severity == Severity::Minor).count(); - let inf = response.issues.iter().filter(|i| i.severity == Severity::Info).count(); + let crit = response + .issues + .iter() + .filter(|i| i.severity == Severity::Critical) + .count(); + let maj = response + .issues + .iter() + .filter(|i| i.severity == Severity::Major) + .count(); + let min = response + .issues + .iter() + .filter(|i| i.severity == Severity::Minor) + .count(); + let inf = response + .issues + .iter() + .filter(|i| i.severity == Severity::Info) + .count(); output.push_str(&format!( "Found {} issue{}: {} {} {} {} {}\n\n", response.issues.len(), if response.issues.len() == 1 { "" } else { "s" }, - format!("{} critical", crit).red(), - format!("{} major", maj).yellow(), - format!("{} minor", min).blue(), - format!("{} info", inf).white(), + format!("{crit} critical").red(), + format!("{maj} major").yellow(), + format!("{min} minor").blue(), + format!("{inf} info").white(), if response.should_block { format!("\n{}", "โš  BLOCKED".red().bold()) } else { @@ -67,6 +82,13 @@ impl Formatter for PrettyFormatter { )); } + // Footer watermark (only when issues found) + output.push_str(&format!( + "\n{}\n{}", + "โ”€".repeat(60).dimmed(), + format!("Reviewed by Cora v{}", env!("CARGO_PKG_VERSION")).dimmed() + )); + Ok(output) } @@ -95,19 +117,35 @@ impl Formatter for PrettyFormatter { return Ok(output); } - let crit = response.issues.iter().filter(|i| i.severity == Severity::Critical).count(); - let maj = response.issues.iter().filter(|i| i.severity == Severity::Major).count(); - let min = response.issues.iter().filter(|i| i.severity == Severity::Minor).count(); - let inf = response.issues.iter().filter(|i| i.severity == Severity::Info).count(); + let crit = response + .issues + .iter() + .filter(|i| i.severity == Severity::Critical) + .count(); + let maj = response + .issues + .iter() + .filter(|i| i.severity == Severity::Major) + .count(); + let min = response + .issues + .iter() + .filter(|i| i.severity == Severity::Minor) + .count(); + let inf = response + .issues + .iter() + .filter(|i| i.severity == Severity::Info) + .count(); output.push_str(&format!( "Found {} issue{}: {} {} {} {} {}\n\n", response.issues.len(), if response.issues.len() == 1 { "" } else { "s" }, - format!("{} critical", crit).red(), - format!("{} major", maj).yellow(), - format!("{} minor", min).blue(), - format!("{} info", inf).white(), + format!("{crit} critical").red(), + format!("{maj} major").yellow(), + format!("{min} minor").blue(), + format!("{inf} info").white(), if response.should_block { format!("\n{}", "โš  BLOCKED".red().bold()) } else { @@ -128,11 +166,21 @@ impl Formatter for PrettyFormatter { )); } + // Footer watermark (only when issues found) + if !response.issues.is_empty() { + output.push_str(&format!( + "\n{}\n{}", + "โ”€".repeat(60).dimmed(), + format!("Reviewed by Cora v{}", env!("CARGO_PKG_VERSION")).dimmed() + )); + } + Ok(output) } } /// Format a single issue with colors and icons. +#[allow(clippy::format_push_string)] fn format_issue_pretty(issue: &ReviewIssue, num: usize) -> String { let mut out = String::new(); @@ -147,7 +195,7 @@ fn format_issue_pretty(issue: &ReviewIssue, num: usize) -> String { out.push_str(&format!("{icon} [{sev_colored}] ")); if let Some(ref itype) = issue.issue_type { - out.push_str(&format!("{}: ", itype)); + out.push_str(&format!("{itype}: ")); } // File:line location @@ -159,7 +207,11 @@ fn format_issue_pretty(issue: &ReviewIssue, num: usize) -> String { out.push('\n'); // Title - out.push_str(&format!(" {} {}\n", format!("#{num}").dimmed(), issue.title.bold())); + out.push_str(&format!( + " {} {}\n", + format!("#{num}").dimmed(), + issue.title.bold() + )); // Body if !issue.body.is_empty() { @@ -171,13 +223,176 @@ fn format_issue_pretty(issue: &ReviewIssue, num: usize) -> String { // Suggested fix if let Some(ref fix) = issue.suggested_fix { if !fix.trim().is_empty() { - out.push_str(&format!( - " {}\n", - "๐Ÿ’ก Suggested fix:".green().bold() - )); - out.push_str(&format!(" {}\n", fix)); + out.push_str(&format!(" {}\n", "๐Ÿ’ก Suggested fix:".green().bold())); + out.push_str(&format!(" {fix}\n")); } } out } + +#[cfg(test)] +mod tests { + use super::*; + use crate::engine::{ReviewIssue, ScanResponse, Severity}; + + fn sample_issue() -> ReviewIssue { + ReviewIssue { + file: "src/main.rs".to_string(), + line: Some(42), + severity: Severity::Critical, + issue_type: Some("security".to_string()), + title: "SQL Injection".to_string(), + body: "User input is concatenated directly into SQL query.".to_string(), + suggested_fix: Some("Use parameterized queries.".to_string()), + } + } + + fn sample_response() -> ReviewResponse { + ReviewResponse { + issues: vec![sample_issue()], + summary: "Found 1 critical issue.".to_string(), + tokens_used: None, + should_block: false, + } + } + + fn empty_response() -> ReviewResponse { + ReviewResponse { + issues: vec![], + summary: String::new(), + tokens_used: None, + should_block: false, + } + } + + fn blocked_response() -> ReviewResponse { + ReviewResponse { + issues: vec![sample_issue()], + summary: String::new(), + tokens_used: None, + should_block: true, + } + } + + fn sample_scan_response() -> ScanResponse { + ScanResponse { + issues: vec![sample_issue()], + summary: "Scan done.".to_string(), + files_scanned: 10, + lines_scanned: 500, + tokens_used: None, + should_block: false, + } + } + + #[test] + fn format_review_contains_file() { + let fmt = PrettyFormatter; + let output = fmt.format_review(&sample_response()).unwrap(); + assert!(output.contains("src/main.rs")); + } + + #[test] + fn format_review_contains_title() { + let fmt = PrettyFormatter; + let output = fmt.format_review(&sample_response()).unwrap(); + assert!(output.contains("SQL Injection")); + } + + #[test] + fn format_review_contains_header() { + let fmt = PrettyFormatter; + let output = fmt.format_review(&sample_response()).unwrap(); + assert!(output.contains("Cora Code Review Results")); + } + + #[test] + fn format_review_empty_issues_shows_no_issues() { + let fmt = PrettyFormatter; + let output = fmt.format_review(&empty_response()).unwrap(); + assert!(output.contains("No issues found")); + // No watermark when no issues + assert!(!output.contains("Reviewed by Cora")); + } + + #[test] + fn format_review_with_issues_has_watermark() { + let fmt = PrettyFormatter; + let output = fmt.format_review(&sample_response()).unwrap(); + assert!(output.contains("Reviewed by Cora v")); + } + + #[test] + fn format_scan_with_issues_has_watermark() { + let fmt = PrettyFormatter; + let output = fmt.format_scan(&sample_scan_response()).unwrap(); + assert!(output.contains("Reviewed by Cora v")); + } + + #[test] + fn format_review_with_summary() { + let fmt = PrettyFormatter; + let output = fmt.format_review(&sample_response()).unwrap(); + assert!(output.contains("Found 1 critical issue.")); + } + + #[test] + fn format_review_blocked_shows_blocked() { + let fmt = PrettyFormatter; + let output = fmt.format_review(&blocked_response()).unwrap(); + assert!(output.contains("BLOCKED")); + } + + #[test] + fn format_review_issue_count() { + let fmt = PrettyFormatter; + let output = fmt.format_review(&sample_response()).unwrap(); + assert!(output.contains("1 issue")); + } + + #[test] + fn format_review_issue_type_displayed() { + let fmt = PrettyFormatter; + let output = fmt.format_review(&sample_response()).unwrap(); + assert!(output.contains("security")); + } + + #[test] + fn format_scan_contains_header() { + let fmt = PrettyFormatter; + let output = fmt.format_scan(&sample_scan_response()).unwrap(); + assert!(output.contains("Cora Project Scan Results")); + } + + #[test] + fn format_scan_contains_file_line_count() { + let fmt = PrettyFormatter; + let output = fmt.format_scan(&sample_scan_response()).unwrap(); + assert!(output.contains("10 files")); + assert!(output.contains("500 lines")); + } + + #[test] + fn format_issue_pretty_no_line() { + let issue = ReviewIssue { + file: "README.md".to_string(), + line: None, + severity: Severity::Info, + issue_type: None, + title: "Typo".to_string(), + body: String::new(), + suggested_fix: None, + }; + let out = format_issue_pretty(&issue, 1); + assert!(out.contains("README.md")); + assert!(!out.contains("README.md:")); + } + + #[test] + fn format_issue_pretty_with_fix() { + let out = format_issue_pretty(&sample_issue(), 1); + assert!(out.contains("Suggested fix")); + assert!(out.contains("parameterized")); + } +} diff --git a/src/formatters/sarif.rs b/src/formatters/sarif.rs index 9d48f9f0..24577e0b 100644 --- a/src/formatters/sarif.rs +++ b/src/formatters/sarif.rs @@ -1,6 +1,5 @@ use anyhow::Result; -use chrono::Utc; -use serde_json::{json, Value}; +use serde_json::{Value, json}; use crate::engine::{ReviewIssue, ReviewResponse, ScanResponse, Severity}; use crate::formatters::Formatter; @@ -8,7 +7,7 @@ use crate::formatters::Formatter; /// SARIF formatter for GitHub Code Scanning integration. /// /// Output conforms to the SARIF v2.1.0 specification: -/// https://docs.oasis-open.org/sarif/sarif/v2.1.0/ +/// / pub struct SarifFormatter; impl Formatter for SarifFormatter { @@ -27,30 +26,38 @@ impl Formatter for SarifFormatter { /// Build a SARIF v2.1.0 document from a list of issues. fn build_sarif(issues: &[ReviewIssue]) -> Value { - let rules: Vec = issues - .iter() - .map(|issue| { - json!({ - "id": issue.issue_type.as_ref().map(|t| t.to_string()).unwrap_or_else(|| "unknown".to_string()), - "shortDescription": { - "text": issue.title.clone() - }, - "fullDescription": { - "text": issue.body.clone() - }, - "helpUri": null, - "defaultConfiguration": { - "level": severity_to_sarif_level(&issue.severity) - } - }) - }) - .collect(); + // Deduplicate rules by id (multiple issues can share same rule) + let mut rule_map = serde_json::Map::new(); + for issue in issues { + let rule_id = issue + .issue_type + .clone() + .unwrap_or_else(|| "unknown".to_string()); + if !rule_map.contains_key(&rule_id) { + rule_map.insert( + rule_id.clone(), + json!({ + "id": rule_id, + "shortDescription": { + "text": issue.title.clone() + }, + "fullDescription": { + "text": issue.body.clone() + }, + "defaultConfiguration": { + "level": severity_to_sarif_level(&issue.severity) + } + }), + ); + } + } + let rules: Vec = rule_map.into_values().collect(); let results: Vec = issues .iter() .map(|issue| { let mut result = json!({ - "ruleId": issue.issue_type.as_ref().map(|t| t.to_string()).unwrap_or_else(|| "unknown".to_string()), + "ruleId": issue.issue_type.clone().unwrap_or_else(|| "unknown".to_string()), "level": severity_to_sarif_level(&issue.severity), "message": { "text": issue.body.clone() @@ -73,17 +80,23 @@ fn build_sarif(issues: &[ReviewIssue]) -> Value { // Add fix suggestion if available if let Some(ref fix) = issue.suggested_fix { - let mut replacements = Vec::new(); - replacements.push(json!({ - "description": { - "text": "Suggested fix" - } - // We don't have a full replacement spec, but we can include the text - })); result["fixes"] = json!([{ "description": { "text": fix.clone() - } + }, + "artifactChanges": [{ + "artifactLocation": { + "uri": issue.file.clone() + }, + "replacements": [{ + "deletedRegion": { + "startLine": issue.line.unwrap_or(1) + }, + "insertedContent": { + "text": fix.clone() + } + }] + }] }]); } @@ -91,24 +104,33 @@ fn build_sarif(issues: &[ReviewIssue]) -> Value { }) .collect(); + // Build invocations array with watermark + let invocations = if issues.is_empty() { + json!([]) + } else { + json!([{ + "executionSuccessful": true, + "properties": { + "cora.watermark": format!("Reviewed by CodeCora v{}", env!("CARGO_PKG_VERSION")) + } + }]) + }; + json!({ "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json", "version": "2.1.0", "runs": [{ "tool": { "driver": { - "name": "Cora", + "name": "CodeCora", + "fullName": "codecoradev/cora-cli", "version": env!("CARGO_PKG_VERSION"), "informationUri": env!("CARGO_PKG_REPOSITORY"), "rules": rules } }, "results": results, - "invocation": { - "executionSuccessful": true, - "startTimeUtc": Utc::now().to_rfc3339(), - "endTimeUtc": Utc::now().to_rfc3339() - } + "invocations": invocations }] }) } @@ -116,9 +138,218 @@ fn build_sarif(issues: &[ReviewIssue]) -> Value { /// Map our severity to SARIF failure level. fn severity_to_sarif_level(severity: &Severity) -> &str { match severity { - Severity::Critical => "error", - Severity::Major => "error", + Severity::Critical | Severity::Major => "error", Severity::Minor => "warning", Severity::Info => "note", } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::engine::{ReviewIssue, Severity}; + + fn sample_issue() -> ReviewIssue { + ReviewIssue { + file: "src/main.rs".to_string(), + line: Some(42), + severity: Severity::Critical, + issue_type: Some("security".to_string()), + title: "SQL Injection".to_string(), + body: "User input concatenated into query.".to_string(), + suggested_fix: Some("Use parameterized queries.".to_string()), + } + } + + fn sample_response() -> ReviewResponse { + ReviewResponse { + issues: vec![sample_issue()], + summary: "Found 1 critical issue.".to_string(), + tokens_used: None, + should_block: false, + } + } + + fn empty_response() -> ReviewResponse { + ReviewResponse { + issues: vec![], + summary: String::new(), + tokens_used: None, + should_block: false, + } + } + + #[test] + fn sarif_output_is_valid_json() { + let fmt = SarifFormatter; + let output = fmt.format_review(&sample_response()).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&output).unwrap(); + assert!(parsed.is_object()); + } + + #[test] + fn sarif_has_required_schema_field() { + let fmt = SarifFormatter; + let output = fmt.format_review(&sample_response()).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&output).unwrap(); + assert!(parsed["$schema"].as_str().unwrap().contains("sarif-schema")); + } + + #[test] + fn sarif_has_version() { + let fmt = SarifFormatter; + let output = fmt.format_review(&sample_response()).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&output).unwrap(); + assert_eq!(parsed["version"].as_str().unwrap(), "2.1.0"); + } + + #[test] + fn sarif_has_runs() { + let fmt = SarifFormatter; + let output = fmt.format_review(&sample_response()).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&output).unwrap(); + let runs = parsed["runs"].as_array().unwrap(); + assert_eq!(runs.len(), 1); + } + + #[test] + fn sarif_tool_driver_name_is_codecora() { + let fmt = SarifFormatter; + let output = fmt.format_review(&sample_response()).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&output).unwrap(); + assert_eq!( + parsed["runs"][0]["tool"]["driver"]["name"] + .as_str() + .unwrap(), + "CodeCora" + ); + } + + #[test] + fn sarif_tool_driver_has_fullname_codecoradev() { + let fmt = SarifFormatter; + let output = fmt.format_review(&sample_response()).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&output).unwrap(); + assert_eq!( + parsed["runs"][0]["tool"]["driver"]["fullName"] + .as_str() + .unwrap(), + "codecoradev/cora-cli" + ); + } + + #[test] + fn sarif_results_contain_issue() { + let fmt = SarifFormatter; + let output = fmt.format_review(&sample_response()).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&output).unwrap(); + let results = parsed["runs"][0]["results"].as_array().unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0]["level"].as_str().unwrap(), "error"); // critical โ†’ error + } + + #[test] + fn sarif_empty_issues_produces_valid_output() { + let fmt = SarifFormatter; + let output = fmt.format_review(&empty_response()).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&output).unwrap(); + let results = parsed["runs"][0]["results"].as_array().unwrap(); + assert!(results.is_empty()); + // No watermark invocations when no issues + let invocations = parsed["runs"][0]["invocations"].as_array().unwrap(); + assert!(invocations.is_empty()); + } + + #[test] + fn sarif_with_issues_has_watermark_invocation() { + let fmt = SarifFormatter; + let output = fmt.format_review(&sample_response()).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&output).unwrap(); + let invocations = parsed["runs"][0]["invocations"].as_array().unwrap(); + assert_eq!(invocations.len(), 1); + let wm = &invocations[0]["properties"]["cora.watermark"]; + let wm_str = wm.as_str().unwrap(); + assert!(wm_str.contains("Cora")); + assert!(wm_str.contains('v')); + } + + #[test] + fn sarif_location_includes_line() { + let fmt = SarifFormatter; + let output = fmt.format_review(&sample_response()).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&output).unwrap(); + let region = &parsed["runs"][0]["results"][0]["locations"][0]["physicalLocation"]["region"]; + assert_eq!(region["startLine"].as_u64().unwrap(), 42); + } + + #[test] + fn sarif_scan_format_also_valid() { + let fmt = SarifFormatter; + let scan = ScanResponse { + issues: vec![sample_issue()], + summary: "Done.".to_string(), + files_scanned: 5, + lines_scanned: 200, + tokens_used: None, + should_block: false, + }; + let output = fmt.format_scan(&scan).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&output).unwrap(); + assert!(parsed["$schema"].as_str().is_some()); + assert_eq!(parsed["runs"][0]["results"].as_array().unwrap().len(), 1); + } + + #[test] + fn sarif_severity_to_level_critical_is_error() { + assert_eq!(severity_to_sarif_level(&Severity::Critical), "error"); + } + + #[test] + fn sarif_severity_to_level_major_is_error() { + assert_eq!(severity_to_sarif_level(&Severity::Major), "error"); + } + + #[test] + fn sarif_severity_to_level_minor_is_warning() { + assert_eq!(severity_to_sarif_level(&Severity::Minor), "warning"); + } + + #[test] + fn sarif_severity_to_level_info_is_note() { + assert_eq!(severity_to_sarif_level(&Severity::Info), "note"); + } + + #[test] + fn sarif_issue_without_line_has_no_region() { + let issue = ReviewIssue { + file: "src/lib.rs".to_string(), + line: None, + severity: Severity::Info, + issue_type: None, + title: "No line".to_string(), + body: "No line info.".to_string(), + suggested_fix: None, + }; + let response = ReviewResponse { + issues: vec![issue], + summary: String::new(), + tokens_used: None, + should_block: false, + }; + let fmt = SarifFormatter; + let output = fmt.format_review(&response).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&output).unwrap(); + let result = &parsed["runs"][0]["results"][0]; + let loc = &result["locations"][0]["physicalLocation"]; + assert!(loc.get("region").is_none()); + } + + #[test] + fn sarif_issue_with_fix() { + let fmt = SarifFormatter; + let output = fmt.format_review(&sample_response()).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&output).unwrap(); + let result = &parsed["runs"][0]["results"][0]; + assert!(result.get("fixes").is_some()); + } +} diff --git a/src/git/diff.rs b/src/git/diff.rs index 60ae562c..af90e61c 100644 --- a/src/git/diff.rs +++ b/src/git/diff.rs @@ -1,92 +1,159 @@ use std::path::Path; use std::process::Command; -use anyhow::{Context, Result}; +use crate::error::CoraError; use git2::Repository; +/// Characters that should not appear in git refs passed to our diff commands. +/// This prevents shell injection and path traversal via crafted ref names. +const DANGEROUS_REF_CHARS: &[char] = &[ + ';', '&', '|', '`', '$', '(', ')', '<', '>', '{', '}', '\\', '\n', '\r', '\t', +]; + +/// Validate a git ref string for safety before passing it to git commands. +/// +/// Rejects refs containing shell metacharacters or path traversal sequences (..). +fn validate_ref(ref_str: &str) -> std::result::Result<(), CoraError> { + if ref_str.contains("..") { + // ".." is only allowed in the middle of range expressions like "HEAD..HEAD" or "main...HEAD" + // But we need to be careful about path traversal. Allow it only for git range syntax. + // Actually, git diff ranges use ".." and "..." which are valid git syntax. + // The risk is when ".." appears as part of a path (e.g., "../../etc/passwd"). + // Since git refs shouldn't contain path components with "..", we reject refs + // that start with ".." or contain "/.." or ".." at the end. + if ref_str.starts_with("..") || ref_str.contains("/..") || ref_str.ends_with("..") { + return Err(CoraError::InvalidRef(format!( + "invalid ref '{ref_str}': contains path traversal sequence '..'" + ))); + } + } + + if ref_str.contains(DANGEROUS_REF_CHARS) { + let found: String = ref_str + .chars() + .filter(|c| DANGEROUS_REF_CHARS.contains(c)) + .collect(); + return Err(CoraError::InvalidRef(format!( + "invalid ref '{ref_str}': contains unsafe characters: '{found}'" + ))); + } + + Ok(()) +} + /// Open the git repository at or above the current working directory. -pub fn open_repo() -> Result { - Repository::discover(std::env::current_dir()?) - .context("not inside a git repository") +pub fn open_repo() -> std::result::Result { + Repository::discover(std::env::current_dir()?).map_err(|_| CoraError::NotInGitRepo) } /// Run a git command and return its stdout as a string. -fn git_cmd(args: &[&str]) -> Result { +fn git_cmd(args: &[&str]) -> std::result::Result { let output = Command::new("git") .args(args) .output() - .context("failed to execute git command")?; + .map_err(|e| CoraError::GitCommand { + command: args.join(" "), + stderr: e.to_string(), + })?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); - anyhow::bail!("git command failed: {stderr}"); + return Err(CoraError::GitCommand { + command: args.join(" "), + stderr: stderr.to_string(), + }); } - Ok(String::from_utf8(output.stdout) - .context("git output is not valid UTF-8")?) + String::from_utf8(output.stdout).map_err(|e| CoraError::GitCommand { + command: args.join(" "), + stderr: e.to_string(), + }) } /// Get the diff of currently staged changes (git diff --cached). -pub fn get_staged_diff() -> Result { +pub fn get_staged_diff() -> std::result::Result { git_cmd(&["diff", "--cached"]) } /// Get the diff of unstaged changes (working tree vs index). -pub fn get_unstaged_diff() -> Result { +pub fn get_unstaged_diff() -> std::result::Result { git_cmd(&["diff"]) } /// Get the diff between the current branch and `base_branch`. -pub fn get_branch_diff(base_branch: &str) -> Result { - let arg = format!("{}...HEAD", base_branch); +pub fn get_branch_diff(base_branch: &str) -> std::result::Result { + validate_ref(base_branch)?; + let arg = format!("{base_branch}...HEAD"); git_cmd(&["diff", &arg]) } /// Get the diff of unpushed commits (HEAD vs @{u}). -pub fn get_unpushed_diff() -> Result { +pub fn get_unpushed_diff() -> std::result::Result { git_cmd(&["log", "-p", "@{u}..HEAD"]) } +/// Get the diff for a commit reference. +/// +/// - If the ref contains `..` (e.g. `HEAD~3..HEAD`), uses `git diff `. +/// - Otherwise (e.g. `HEAD`, `abc123`), uses `git show --format=""` to +/// get just the diff without commit metadata. +pub fn get_commit_diff(ref_str: &str) -> std::result::Result { + validate_ref(ref_str)?; + if ref_str.contains("..") { + git_cmd(&["diff", ref_str]) + } else { + git_cmd(&["show", ref_str, "--format="]) + } +} + /// Get the current branch name. -pub fn get_current_branch() -> Result { +pub fn get_current_branch() -> std::result::Result { let output = Command::new("git") .args(["rev-parse", "--abbrev-ref", "HEAD"]) .output() - .context("failed to execute git rev-parse")?; + .map_err(|e| CoraError::GitCommand { + command: "git rev-parse --abbrev-ref HEAD".into(), + stderr: e.to_string(), + })?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); - anyhow::bail!("git rev-parse failed: {stderr}"); + return Err(CoraError::GitCommand { + command: "git rev-parse --abbrev-ref HEAD".into(), + stderr: stderr.to_string(), + }); } - let name = String::from_utf8(output.stdout) - .context("branch name is not valid UTF-8")?; + let name = String::from_utf8(output.stdout).map_err(|e| CoraError::GitCommand { + command: "git rev-parse".into(), + stderr: e.to_string(), + })?; let name = name.trim().to_string(); if name.is_empty() || name == "HEAD" { - anyhow::bail!("HEAD is detached -- cannot determine branch name"); + return Err(CoraError::HeadDetached); } Ok(name) } -/// Try to get repository info: (owner, repo_name, branch). +/// Try to get repository info: (owner, `repo_name`, branch). /// Owner is derived from the remote URL if possible. -pub fn get_repo_info() -> Result<(String, String, String)> { +pub fn get_repo_info() -> std::result::Result<(String, String, String), CoraError> { let repo = open_repo()?; let branch = get_current_branch()?; let remote_url = repo .find_remote("origin") .ok() - .and_then(|r| r.url().map(|s| s.to_string())) + .and_then(|r| r.url().map(std::string::ToString::to_string)) .unwrap_or_default(); let (owner, repo_name) = parse_remote_url(&remote_url); Ok((owner, repo_name, branch)) } -/// Parse a remote URL into (owner, repo_name). +/// Parse a remote URL into (owner, `repo_name`). fn parse_remote_url(url: &str) -> (String, String) { // Handle git@host:owner/repo.git, https://host/owner/repo.git, etc. let clean = url @@ -107,8 +174,55 @@ fn parse_remote_url(url: &str) -> (String, String) { } /// Check if a path is inside a git worktree. +/// +/// Kept for API completeness โ€” useful for pre-commit hooks and future commands +/// that need to guard against running outside a repository. +#[allow(dead_code)] pub fn is_inside_git_repo(dir: Option<&Path>) -> bool { let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); let search_dir = dir.unwrap_or(&cwd); Repository::discover(search_dir).is_ok() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validate_ref_simple_name() { + assert!(validate_ref("main").is_ok()); + assert!(validate_ref("HEAD").is_ok()); + assert!(validate_ref("feature/my-branch").is_ok()); + assert!(validate_ref("abc123").is_ok()); + } + + #[test] + fn validate_ref_range_syntax() { + assert!(validate_ref("HEAD~3..HEAD").is_ok()); + assert!(validate_ref("main...develop").is_ok()); + assert!(validate_ref("abc123..def456").is_ok()); + } + + #[test] + fn validate_ref_rejects_path_traversal() { + assert!(validate_ref("../../etc/passwd").is_err()); + assert!(validate_ref("..HEAD").is_err()); + assert!(validate_ref("HEAD..").is_err()); + assert!(validate_ref("branch/../other").is_err()); + } + + #[test] + fn validate_ref_rejects_shell_metacharacters() { + assert!(validate_ref("main;rm -rf /").is_err()); + assert!(validate_ref("main`whoami`").is_err()); + assert!(validate_ref("$(whoami)").is_err()); + assert!(validate_ref("main && echo pwned").is_err()); + assert!(validate_ref("main | cat").is_err()); + assert!(validate_ref("main\nextra").is_err()); + } + + #[test] + fn validate_ref_rejects_angle_brackets() { + assert!(validate_ref("main > /etc/passwd").is_err()); + } +} diff --git a/src/git/files.rs b/src/git/files.rs index 2e1286b5..7d6d933a 100644 --- a/src/git/files.rs +++ b/src/git/files.rs @@ -1,190 +1,2 @@ -use std::path::Path; - -use anyhow::{Context, Result}; -use git2::{Repository, Status, StatusOptions, StatusShow, TreeWalkMode, TreeWalkResult}; -use glob::Pattern; - -/// Open the git repository at or above the current working directory. -pub fn open_repo() -> Result { - Repository::discover(std::env::current_dir()?) - .context("not inside a git repository") -} - -/// List all tracked files in the repository. -pub fn list_tracked_files() -> Result> { - let repo = open_repo()?; - let head = repo.head().context("no HEAD reference")?; - let commit = head.peel_to_commit().context("HEAD is not a commit")?; - let tree = commit.tree().context("failed to read tree")?; - - let mut files = Vec::new(); - tree.walk(TreeWalkMode::PreOrder, |_root, entry| { - if entry.kind() == Some(git2::ObjectType::Blob) { - if let Some(name) = entry.name() { - if !is_ignored_dir(name) { - files.push(name.to_string()); - } - } - } - TreeWalkResult::Ok - }) - .context("failed to walk tree")?; - - Ok(files) -} - -/// Check if a file path falls in a commonly ignored directory. -fn is_ignored_dir(path: &str) -> bool { - let ignored_prefixes = [ - "node_modules/", - ".git/", - "vendor/", - "__pycache__/", - ".venv/", - "venv/", - "target/", - "build/", - "dist/", - ".next/", - "coverage/", - ]; - for prefix in &ignored_prefixes { - if path.starts_with(prefix) { - return true; - } - } - false -} - -/// List files that changed on the current branch compared to `base_branch`. -pub fn list_changed_files(base_branch: &str) -> Result> { - let repo = open_repo()?; - - let base_oid = repo - .revparse_single(base_branch) - .with_context(|| format!("cannot resolve branch '{base_branch}'"))? - .peel_to_commit()? - .id(); - - let _head_oid = repo.head()?.peel_to_commit()?.id(); - - let base_tree = repo.find_tree(base_oid).context("failed to find base tree")?; - let head_commit = repo.head()?.peel_to_commit().context("HEAD is not a commit")?; - let head_tree = repo.find_tree(head_commit.tree_id()).context("failed to find HEAD tree")?; - - let mut opts = git2::DiffOptions::new(); - let diff = repo - .diff_tree_to_tree(Some(&base_tree), Some(&head_tree), Some(&mut opts)) - .context("failed to diff trees")?; - - let mut files = Vec::new(); - for delta in diff.deltas() { - if let Some(path) = delta.new_file().path() { - if let Some(name) = path.to_str() { - files.push(name.to_string()); - } - } - } - - Ok(files) -} - -/// List files that are currently staged (in the index). -pub fn list_staged_files() -> Result> { - let repo = open_repo()?; - let head = repo.head().ok(); - let head_tree = head - .and_then(|h| h.peel_to_commit().ok()) - .and_then(|c| c.tree().ok()); - - let mut opts = git2::DiffOptions::new(); - let diff = repo - .diff_tree_to_index(head_tree.as_ref(), None, Some(&mut opts)) - .context("failed to diff staged files")?; - - let mut files = Vec::new(); - for delta in diff.deltas() { - if let Some(path) = delta.new_file().path() { - if let Some(name) = path.to_str() { - files.push(name.to_string()); - } - } - } - - Ok(files) -} - -/// List all modified/added/deleted files (staged + unstaged). -pub fn list_all_changed_files() -> Result> { - let repo = open_repo()?; - - let mut opts = StatusOptions::new(); - opts.show(StatusShow::IndexAndWorkdir) - .include_untracked(true) - .recurse_untracked_dirs(true); - - let statuses = repo - .statuses(Some(&mut opts)) - .context("failed to get git status")?; - - let mut files = Vec::new(); - for entry in statuses.iter() { - if entry.status().intersects( - Status::INDEX_NEW - | Status::INDEX_MODIFIED - | Status::INDEX_DELETED - | Status::WT_MODIFIED - | Status::WT_NEW - | Status::WT_DELETED, - ) { - if let Some(path) = entry.path() { - files.push(path.to_string()); - } - } - } - - files.sort(); - files.dedup(); - Ok(files) -} - -/// Filter a list of file paths using glob include/exclude patterns. -pub fn filter_by_globs( - files: &[String], - include: &[String], - exclude: &[String], -) -> Vec { - let include_patterns: Vec = include - .iter() - .filter_map(|p| Pattern::new(p).ok()) - .collect(); - - let exclude_patterns: Vec = exclude - .iter() - .filter_map(|p| Pattern::new(p).ok()) - .collect(); - - files - .iter() - .filter(|f| { - let path = Path::new(f); - let included = include_patterns.is_empty() - || include_patterns.iter().any(|p| p.matches_path(path)); - - let excluded = exclude_patterns.iter().any(|p| p.matches_path(path)); - - included && !excluded - }) - .cloned() - .collect() -} - -/// Check if a given path is gitignored. -pub fn is_gitignored(path: &Path) -> Result { - let repo = open_repo()?; - let relative = path - .strip_prefix(repo.workdir().unwrap_or(path)) - .unwrap_or(path); - - Ok(repo.is_path_ignored(relative)?) -} +// Git file utility functions. +// Currently empty โ€” functions migrated to individual command modules as needed. diff --git a/src/hook/install.rs b/src/hook/install.rs index 1dd94c53..a62e3f9f 100644 --- a/src/hook/install.rs +++ b/src/hook/install.rs @@ -1,4 +1,3 @@ - use anyhow::{Context, Result}; use tracing::debug; @@ -62,16 +61,13 @@ pub fn uninstall_hook() -> Result<()> { } if backup_path.is_file() { - std::fs::rename(&backup_path, &hook_path) - .context("failed to restore backup hook")?; + std::fs::rename(&backup_path, &hook_path).context("failed to restore backup hook")?; debug!("restored hook from backup"); } else if pre_backup.is_file() { - std::fs::rename(&pre_backup, &hook_path) - .context("failed to restore pre-cora backup")?; + std::fs::rename(&pre_backup, &hook_path).context("failed to restore pre-cora backup")?; debug!("restored pre-cora hook from backup"); } else { - std::fs::remove_file(&hook_path) - .context("failed to remove hook")?; + std::fs::remove_file(&hook_path).context("failed to remove hook")?; debug!("removed cora hook"); } @@ -106,6 +102,10 @@ fn find_git_hooks_dir() -> Result { } /// Check whether the cora pre-commit hook is installed. +/// +/// Kept for API completeness โ€” useful for future `cora hook status` and +/// guard logic in pre-commit hook template. +#[allow(dead_code)] pub fn is_hook_installed() -> Result { let hooks_dir = find_git_hooks_dir()?; let hook_path = hooks_dir.join("pre-commit"); diff --git a/src/main.rs b/src/main.rs index a2cea44c..f06531e9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,20 +1,19 @@ -// Suppress unused warnings for utility functions not yet called from main -// These will be used as the codebase grows -#![allow(dead_code)] - use anyhow::Result; use clap::{Parser, Subcommand}; +use colored::Colorize; use tracing::Level; use tracing_subscriber::FmtSubscriber; mod commands; mod config; mod engine; +mod error; mod formatters; mod git; mod hook; +mod progress; -use commands::{auth, hook_cmd, init, review, scan}; +use commands::{auth, completion, config_cmd, hook_cmd, init, providers, review, scan, upload}; use config::loader; use formatters::OutputFormat; @@ -65,7 +64,7 @@ struct GlobalOptions { #[clap(long, global = true, env = "CORA_BASE_URL")] pub base_url: Option, - /// API key (or set CORA_API_KEY env var, or use `cora auth login`) + /// API key (or set `CORA_API_KEY` env var, or use `cora auth login`) #[clap(long, global = true, env = "CORA_API_KEY")] pub api_key: Option, @@ -90,9 +89,62 @@ enum Command { #[clap(long)] base: Option, + /// Review changes from a git commit ref (e.g. HEAD, HEAD~3..HEAD, abc123) + #[clap(long)] + commit: Option, + + /// Read diff from a file instead of git + #[clap(long)] + diff_file: Option, + /// Review unstaged changes (working tree) #[clap(long)] unstaged: bool, + + /// Override max diff size in chars (default: 50000 from config) + #[clap(long, name = "CHARS")] + max_diff_size: Option, + + /// Stream LLM response tokens in real-time + #[clap(long)] + stream: bool, + + /// Output structured NDJSON progress events to stderr + #[clap(long)] + progress: bool, + + /// Suppress all output except the formatted review result + #[clap(long, short)] + quiet: bool, + + /// Filter results by minimum severity (info, minor, major, critical) + #[clap(long, value_parser = ["info", "minor", "major", "critical"])] + severity: Option, + + /// Disable review caching + #[clap(long)] + no_cache: bool, + + /// CI mode: skip diff size limit, exit 2 if any findings + #[clap(long)] + ci: bool, + + /// Upload SARIF output to GitHub Code Scanning after review + /// (implies --format sarif) + #[clap(long)] + upload: bool, + + /// GitHub repository for upload (default: from git remote origin) + #[clap(long, env = "GITHUB_REPOSITORY")] + repo: Option, + + /// GitHub ref for upload (default: current branch) + #[clap(long, env = "GITHUB_REF")] + ref_name: Option, + + /// GitHub token for upload (default: `GITHUB_TOKEN` env var) + #[clap(long, env = "GITHUB_TOKEN")] + token: Option, }, /// Scan a project directory for issues @@ -112,6 +164,32 @@ enum Command { /// Additional file extensions to scan #[clap(long)] extensions: Vec, + + /// Only scan files changed since last scan (uses ~/.cora/scan-cache.json) + #[clap(long)] + incremental: bool, + + /// Focus areas for review (overrides config) + #[clap(long)] + focus: Vec, + }, + + /// Upload a SARIF file to GitHub Code Scanning + UploadSarif { + /// Path to SARIF file to upload (default: reads from stdin) + file: Option, + + /// GitHub repository (default: from git remote origin) + #[clap(long, env = "GITHUB_REPOSITORY")] + repo: Option, + + /// GitHub ref (default: current branch) + #[clap(long, env = "GITHUB_REF")] + ref_name: Option, + + /// GitHub token (default: `GITHUB_TOKEN` env var) + #[clap(long, env = "GITHUB_TOKEN")] + token: Option, }, /// Create a .cora.yaml config file in the current directory @@ -132,6 +210,21 @@ enum Command { #[command(subcommand)] action: AuthAction, }, + + /// View or set configuration + Config { + #[command(subcommand)] + action: ConfigAction, + }, + + /// List detected/available LLM providers + Providers, + + /// Generate shell completion scripts + Completion { + /// Shell name: bash, zsh, fish, or powershell + shell: String, + }, } #[derive(Subcommand, Debug)] @@ -152,6 +245,25 @@ enum AuthAction { Remove, } +#[derive(Subcommand, Debug)] +enum ConfigAction { + /// Show the current resolved configuration + Show, + /// Set a configuration value (keys: model, provider, `base_url`, format, severity) + Set { + /// Configuration key to set + key: String, + /// Value to assign + value: String, + /// Write to global config (~/.cora/config.yaml) instead of project .cora.yaml + #[clap(long)] + global: bool, + }, + /// Validate the current configuration and report status + Validate, +} + +#[allow(clippy::too_many_lines)] #[tokio::main] async fn main() -> Result<()> { let cli = Cli::parse(); @@ -181,11 +293,83 @@ async fn main() -> Result<()> { // Dispatch based on subcommand let exit_code = match cli.command { - Command::Review { staged, unpushed, base, unstaged } => { - cmd_review(&cli.global, ReviewOpts { staged, unpushed, base, unstaged }).await? + Command::Review { + staged, + unpushed, + base, + commit, + diff_file, + unstaged, + max_diff_size, + stream, + progress, + quiet, + severity, + no_cache, + ci, + upload, + repo, + ref_name, + token, + } => { + cmd_review( + &cli.global, + ReviewOpts { + staged, + unpushed, + base, + commit, + diff_file, + unstaged, + max_diff_size, + stream, + progress, + quiet, + severity, + upload, + repo, + ref_name, + token, + no_cache, + ci, + }, + ) + .await? + } + Command::Scan { + path, + include, + exclude, + extensions, + incremental, + focus, + } => { + cmd_scan( + &cli.global, + ScanOpts { + path, + include, + exclude, + extensions, + incremental, + focus, + }, + ) + .await? } - Command::Scan { path, include, exclude, extensions } => { - cmd_scan(&cli.global, ScanOpts { path, include, exclude, extensions }).await? + Command::UploadSarif { + file, + repo, + ref_name, + token, + } => { + let opts = upload::UploadOptions { + file, + repo, + ref_name, + token, + }; + upload::execute_upload(&opts).await? } Command::Init { force } => { if force { @@ -213,17 +397,50 @@ async fn main() -> Result<()> { } 0 } + Command::Config { action } => match action { + ConfigAction::Show => { + config_cmd::execute_config_show()?; + 0 + } + ConfigAction::Set { key, value, global } => { + config_cmd::execute_config_set(&key, &value, global)?; + 0 + } + ConfigAction::Validate => config_cmd::execute_config_validate()?, + }, + Command::Providers => { + providers::execute_providers(); + 0 + } + Command::Completion { shell } => { + completion::execute_completion(&shell)?; + 0 + } }; std::process::exit(exit_code); } /// Struct to hold review options from CLI. +#[allow(clippy::struct_excessive_bools)] struct ReviewOpts { staged: bool, unpushed: bool, base: Option, + commit: Option, + diff_file: Option, unstaged: bool, + max_diff_size: Option, + stream: bool, + progress: bool, + quiet: bool, + severity: Option, + upload: bool, + repo: Option, + ref_name: Option, + token: Option, + no_cache: bool, + ci: bool, } /// Struct to hold scan options from CLI. @@ -232,6 +449,8 @@ struct ScanOpts { include: Vec, exclude: Vec, extensions: Vec, + incremental: bool, + focus: Vec, } /// Handle the `review` subcommand. @@ -241,24 +460,113 @@ async fn cmd_review(globals: &GlobalOptions, opts: ReviewOpts) -> Result { globals.provider.as_deref(), globals.model.as_deref(), globals.base_url.as_deref(), - globals.api_key.as_deref(), globals.format.as_deref(), globals.no_color, )?; - let llm_config = loader::build_llm_config(&config, globals.api_key.as_deref())?; - let format = resolve_format(globals.format.as_deref(), &config)?; + // If --upload is set, force SARIF format + let effective_format = if opts.upload { + OutputFormat::Sarif + } else { + resolve_format(globals.format.as_deref(), &config)? + }; + + let progress_reporter = if opts.progress { + progress::ProgressReporter::new() + } else { + progress::ProgressReporter::disabled() + }; + + // Emit started event if progress enabled + progress_reporter.started("review", opts.base.as_deref()); let review_opts = review::ReviewOptions { staged: opts.staged, unpushed: opts.unpushed, - base: opts.base, + base: opts.base.clone(), + commit: opts.commit.clone(), + diff_file: opts.diff_file.clone(), unstaged: opts.unstaged, - max_diff_size: None, + max_diff_size: opts.max_diff_size, + stream: opts.stream, + quiet: opts.quiet || opts.progress, + severity: opts.severity.clone(), + no_cache: opts.no_cache, + ci: opts.ci, + }; + + // When streaming and not quiet/progress, show a simpler message + if opts.stream && !opts.quiet && !opts.progress { + eprintln!( + "{}", + format!( + "โณ Streaming review from {} ({})โ€ฆ", + llm_config.provider, llm_config.model + ) + .dimmed() + ); + } + + // Execute the review (returns formatted output) + let result = review::execute_review( + &config, + &llm_config, + &review_opts, + effective_format, + &progress_reporter, + ) + .await?; + + // Print the formatted output + print!("{}", result.output); + + // If --upload, send the SARIF output to GitHub Code Scanning + if opts.upload { + let sarif_content = result.output; + upload_sarif_content(&sarif_content, &opts.repo, &opts.ref_name, &opts.token).await?; + } + + Ok(result.exit_code) +} + +/// Upload a SARIF string to GitHub Code Scanning. +#[allow(clippy::ref_option)] +async fn upload_sarif_content( + sarif_content: &str, + repo: &Option, + ref_name: &Option, + token: &Option, +) -> Result { + use std::io::Write; + + // Write SARIF to a temp file and upload it + let tmp_dir = std::env::temp_dir(); + let tmp_path = tmp_dir.join(format!("cora-sarif-upload-{}.json", std::process::id())); + + { + let mut file = std::fs::File::create(&tmp_path)?; + file.write_all(sarif_content.as_bytes())?; + } + + println!( + "{}", + "\nโ†’ Uploading SARIF to GitHub Code Scanning...".cyan() + ); + + let upload_opts = upload::UploadOptions { + file: Some(tmp_path.to_string_lossy().to_string()), + repo: repo.clone(), + ref_name: ref_name.clone(), + token: token.clone(), }; - review::execute_review(&config, &llm_config, &review_opts, format).await + let exit = upload::execute_upload(&upload_opts).await?; + + // Clean up temp file + let _ = std::fs::remove_file(&tmp_path); + + Ok(exit) } /// Handle the `scan` subcommand. @@ -268,11 +576,9 @@ async fn cmd_scan(globals: &GlobalOptions, opts: ScanOpts) -> Result { globals.provider.as_deref(), globals.model.as_deref(), globals.base_url.as_deref(), - globals.api_key.as_deref(), globals.format.as_deref(), globals.no_color, )?; - let llm_config = loader::build_llm_config(&config, globals.api_key.as_deref())?; let format = resolve_format(globals.format.as_deref(), &config)?; @@ -282,13 +588,21 @@ async fn cmd_scan(globals: &GlobalOptions, opts: ScanOpts) -> Result { include: opts.include, exclude: opts.exclude, extensions: opts.extensions, + incremental: opts.incremental, + focus: opts.focus, }; scan::execute_scan(&config, &llm_config, &scan_opts, format).await } /// Resolve the output format: CLI flag > config > default. -fn resolve_format(cli_format: Option<&str>, config: &crate::config::schema::Config) -> Result { - let fmt_str = cli_format.map(|s| s.to_string()).unwrap_or_else(|| config.output.format.clone()); +fn resolve_format( + cli_format: Option<&str>, + config: &crate::config::schema::Config, +) -> Result { + let fmt_str = cli_format.map_or_else( + || config.output.format.clone(), + std::string::ToString::to_string, + ); OutputFormat::from_str_loose(&fmt_str) } diff --git a/src/progress.rs b/src/progress.rs new file mode 100644 index 00000000..c467c7c5 --- /dev/null +++ b/src/progress.rs @@ -0,0 +1,399 @@ +//! Structured NDJSON progress reporting for the `cora review` command. +//! +//! When `--progress` is set, progress events are written to stderr as one JSON object +//! per line (NDJSON). When not set, the reporter is a no-op with zero overhead. + +use std::io::Write; + +use chrono::Utc; +use serde::Serialize; + +/// NDJSON progress event emitted to stderr. +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum ProgressEvent { + /// Review execution started. + Started { + mode: String, + base: Option, + timestamp: String, + }, + /// Diff has been parsed and stats are available. + ParsingDiff { + files_changed: usize, + lines_changed: usize, + timestamp: String, + }, + /// About to call the LLM. + CallingLlm { + provider: String, + model: String, + timestamp: String, + }, + /// LLM responded. + LlmResponse { + tokens: TokenInfo, + duration_ms: u64, + timestamp: String, + }, + /// Review completed successfully. + Complete { + issues: usize, + blocked: bool, + tokens: TokenInfo, + timestamp: String, + }, + /// An error occurred. + Error { + message: String, + phase: String, + timestamp: String, + }, +} + +/// Token usage information for progress events. +#[derive(Debug, Clone, Serialize)] +pub struct TokenInfo { + pub input: u32, + pub output: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub estimated_cost_usd: Option, +} + +impl TokenInfo { + pub fn from_usage(usage: &crate::engine::types::TokenUsage) -> Self { + TokenInfo { + input: usage.input_tokens, + output: usage.output_tokens, + estimated_cost_usd: if usage.estimated_cost_usd > 0.0 { + Some(usage.estimated_cost_usd) + } else { + None + }, + } + } + + pub fn zero() -> Self { + TokenInfo { + input: 0, + output: 0, + estimated_cost_usd: None, + } + } +} + +/// A progress reporter that emits NDJSON events to stderr. +/// +/// Create with `ProgressReporter::new()` when `--progress` is enabled, +/// or use `ProgressReporter::disabled()` (the default) for a no-op. +#[derive(Debug)] +pub struct ProgressReporter { + enabled: bool, +} + +impl ProgressReporter { + /// Create a no-op progress reporter (when `--progress` is not set). + pub fn disabled() -> Self { + ProgressReporter { enabled: false } + } + + /// Create an active progress reporter (when `--progress` is set). + pub fn new() -> Self { + ProgressReporter { enabled: true } + } + + /// Returns true if this reporter is active. + pub fn is_enabled(&self) -> bool { + self.enabled + } + + fn now_iso() -> String { + Utc::now().to_rfc3339() + } + + fn emit(&self, event: &ProgressEvent) { + if !self.enabled { + return; + } + if let Ok(json) = serde_json::to_string(event) { + let _ = writeln!(std::io::stderr(), "{json}"); + } + } + + /// Emit a "started" event. + pub fn started(&self, mode: &str, base: Option<&str>) { + self.emit(&ProgressEvent::Started { + mode: mode.to_string(), + base: base.map(std::string::ToString::to_string), + timestamp: Self::now_iso(), + }); + } + + /// Emit a "`parsing_diff`" event. + pub fn parsing_diff(&self, files_changed: usize, lines_changed: usize) { + self.emit(&ProgressEvent::ParsingDiff { + files_changed, + lines_changed, + timestamp: Self::now_iso(), + }); + } + + /// Emit a "`calling_llm`" event. + pub fn calling_llm(&self, provider: &str, model: &str) { + self.emit(&ProgressEvent::CallingLlm { + provider: provider.to_string(), + model: model.to_string(), + timestamp: Self::now_iso(), + }); + } + + /// Emit a "`llm_response`" event. + pub fn llm_response(&self, tokens: &TokenInfo, duration_ms: u64) { + self.emit(&ProgressEvent::LlmResponse { + tokens: tokens.clone(), + duration_ms, + timestamp: Self::now_iso(), + }); + } + + /// Emit a "complete" event. + pub fn complete(&self, issues: usize, blocked: bool, tokens: &TokenInfo) { + self.emit(&ProgressEvent::Complete { + issues, + blocked, + tokens: tokens.clone(), + timestamp: Self::now_iso(), + }); + } + + /// Emit an "error" event. + pub fn error(&self, message: &str, phase: &str) { + self.emit(&ProgressEvent::Error { + message: message.to_string(), + phase: phase.to_string(), + timestamp: Self::now_iso(), + }); + } +} + +/// Extract diff statistics: (`files_changed`, `lines_changed`). +pub fn diff_stats(diff: &str) -> (usize, usize) { + let mut files_changed = 0; + let mut lines_changed = 0; + + for line in diff.lines() { + let trimmed = line.trim_start(); + // Count file headers from diff output + if trimmed.starts_with("--- ") || trimmed.starts_with("+++ ") { + // Only count once per file pair (the --- line) + if trimmed.starts_with("--- ") { + files_changed += 1; + } + } + // Count changed lines (lines starting with + or - but not +++ or ---) + if (trimmed.starts_with('+') && !trimmed.starts_with("++")) + || (trimmed.starts_with('-') && !trimmed.starts_with("--")) + { + lines_changed += 1; + } + } + + // files_changed is double-counted from --- lines (each pair has one ---), + // which is correct since we only increment on ---. + (files_changed, lines_changed) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn diff_stats_empty() { + let (files, lines) = diff_stats(""); + assert_eq!(files, 0); + assert_eq!(lines, 0); + } + + #[test] + fn diff_stats_single_file() { + let diff = "\ +--- a/src/main.rs ++++ b/src/main.rs +@@ -1,4 +1,5 @@ + fn main() { +- println!(\"hello\"); ++ println!(\"world\"); ++ let x = 1; + } +"; + let (files, lines) = diff_stats(diff); + assert_eq!(files, 1); + assert_eq!(lines, 3); // -1 +2 +1 + } + + #[test] + fn diff_stats_multiple_files() { + let diff = "\ +--- a/file1.rs ++++ b/file1.rs +@@ -1,3 +1,3 @@ +- old ++ new +--- a/file2.rs ++++ b/file2.rs +@@ -1,3 +1,4 @@ +- foo ++ bar ++ baz +"; + let (files, lines) = diff_stats(diff); + assert_eq!(files, 2); + assert_eq!(lines, 5); // -1 +1 -1 +1 +1 + } + + #[test] + fn disabled_reporter_no_output() { + let reporter = ProgressReporter::disabled(); + assert!(!reporter.is_enabled()); + // These should not panic or write anything + reporter.started("review", None); + reporter.parsing_diff(1, 10); + reporter.calling_llm("openai", "gpt-4o-mini"); + reporter.llm_response(&TokenInfo::zero(), 100); + reporter.complete(0, false, &TokenInfo::zero()); + reporter.error("test error", "test_phase"); + } + + #[test] + fn enabled_reporter_is_enabled() { + let reporter = ProgressReporter::new(); + assert!(reporter.is_enabled()); + } + + #[test] + fn progress_event_started_serializes() { + let event = ProgressEvent::Started { + mode: "review".to_string(), + base: Some("origin/develop".to_string()), + timestamp: "2026-06-02T10:00:00+00:00".to_string(), + }; + let json = serde_json::to_string(&event).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed["status"], "started"); + assert_eq!(parsed["mode"], "review"); + assert_eq!(parsed["base"], "origin/develop"); + } + + #[test] + fn progress_event_parsing_diff_serializes() { + let event = ProgressEvent::ParsingDiff { + files_changed: 12, + lines_changed: 340, + timestamp: "2026-06-02T10:00:00+00:00".to_string(), + }; + let json = serde_json::to_string(&event).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed["status"], "parsing_diff"); + assert_eq!(parsed["files_changed"], 12); + assert_eq!(parsed["lines_changed"], 340); + } + + #[test] + fn progress_event_calling_llm_serializes() { + let event = ProgressEvent::CallingLlm { + provider: "openai".to_string(), + model: "gpt-4o-mini".to_string(), + timestamp: "2026-06-02T10:00:00+00:00".to_string(), + }; + let json = serde_json::to_string(&event).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed["status"], "calling_llm"); + assert_eq!(parsed["provider"], "openai"); + assert_eq!(parsed["model"], "gpt-4o-mini"); + } + + #[test] + fn progress_event_llm_response_serializes() { + let event = ProgressEvent::LlmResponse { + tokens: TokenInfo { + input: 8200, + output: 2400, + estimated_cost_usd: Some(0.003), + }, + duration_ms: 3200, + timestamp: "2026-06-02T10:00:00+00:00".to_string(), + }; + let json = serde_json::to_string(&event).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed["status"], "llm_response"); + assert_eq!(parsed["tokens"]["input"], 8200); + assert_eq!(parsed["tokens"]["output"], 2400); + assert_eq!(parsed["duration_ms"], 3200); + } + + #[test] + fn progress_event_complete_serializes() { + let event = ProgressEvent::Complete { + issues: 3, + blocked: false, + tokens: TokenInfo { + input: 8200, + output: 2400, + estimated_cost_usd: Some(0.003), + }, + timestamp: "2026-06-02T10:00:00+00:00".to_string(), + }; + let json = serde_json::to_string(&event).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed["status"], "complete"); + assert_eq!(parsed["issues"], 3); + assert_eq!(parsed["blocked"], false); + } + + #[test] + fn progress_event_error_serializes() { + let event = ProgressEvent::Error { + message: "API timeout after 120s".to_string(), + phase: "calling_llm".to_string(), + timestamp: "2026-06-02T10:00:00+00:00".to_string(), + }; + let json = serde_json::to_string(&event).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed["status"], "error"); + assert_eq!(parsed["message"], "API timeout after 120s"); + assert_eq!(parsed["phase"], "calling_llm"); + } + + #[test] + fn token_info_zero() { + let tokens = TokenInfo::zero(); + assert_eq!(tokens.input, 0); + assert_eq!(tokens.output, 0); + assert!(tokens.estimated_cost_usd.is_none()); + } + + #[test] + fn token_info_serializes_without_cost_when_zero() { + let tokens = TokenInfo::zero(); + let json = serde_json::to_string(&tokens).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert!( + !parsed + .as_object() + .unwrap() + .contains_key("estimated_cost_usd") + ); + } + + #[test] + fn token_info_serializes_with_cost_when_nonzero() { + let tokens = TokenInfo { + input: 100, + output: 50, + estimated_cost_usd: Some(0.001), + }; + let json = serde_json::to_string(&tokens).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed["estimated_cost_usd"], 0.001); + } +} diff --git a/tests/cli_basic.rs b/tests/cli_basic.rs new file mode 100644 index 00000000..2717669d --- /dev/null +++ b/tests/cli_basic.rs @@ -0,0 +1,198 @@ +use assert_cmd::prelude::*; +use predicates::prelude::*; +use std::io::Write; +use std::process::Command; + +// Binary name matches the [[bin]] in Cargo.toml +fn cora_cmd() -> Command { + Command::cargo_bin("cora").unwrap() +} + +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +// CLI argument parsing tests +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +#[test] +fn cli_version_returns_version_string() { + cora_cmd() + .arg("--version") + .assert() + .success() + .stdout(predicate::str::contains(env!("CARGO_PKG_VERSION"))); +} + +#[test] +fn cli_help_shows_help() { + cora_cmd() + .arg("--help") + .assert() + .stdout(predicate::str::contains("AI Code Review")) + .stdout(predicate::str::contains("Usage: cora")) + .stdout(predicate::str::contains("Commands:")); +} + +#[test] +fn cli_review_help_shows_review_help() { + cora_cmd() + .args(["review", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("Review a git diff")) + .stdout(predicate::str::contains("--staged")) + .stdout(predicate::str::contains("--unpushed")) + .stdout(predicate::str::contains("--base")); +} + +#[test] +fn cli_init_help_shows_init_help() { + cora_cmd() + .args(["init", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("Create a .cora.yaml")) + .stdout(predicate::str::contains("--force")); +} + +#[test] +fn cli_scan_help_shows_scan_help() { + cora_cmd() + .args(["scan", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("Scan a project directory")) + .stdout(predicate::str::contains("--include")) + .stdout(predicate::str::contains("--exclude")); +} + +#[test] +fn cli_auth_help_shows_auth_help() { + cora_cmd() + .args(["auth", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("Manage API key")); +} + +#[test] +fn cli_hook_help_shows_hook_help() { + cora_cmd() + .args(["hook", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("pre-commit git hooks")); +} + +#[test] +fn cli_providers_help_shows_providers_help() { + cora_cmd() + .args(["providers", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("List detected")); +} + +#[test] +fn cli_global_options_shown_in_help() { + cora_cmd() + .arg("--help") + .assert() + .stdout(predicate::str::contains("--config")) + .stdout(predicate::str::contains("--format")) + .stdout(predicate::str::contains("--provider")) + .stdout(predicate::str::contains("--model")) + .stdout(predicate::str::contains("--verbose")); +} + +#[test] +fn cli_no_subcommand_fails() { + // Running without a required subcommand shows usage and exits with error + let output = cora_cmd().assert().failure(); + // The help message appears on stderr when no subcommand is given + output.stderr(predicate::str::contains("Usage: cora")); +} + +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +// Config file loading tests (via CLI) +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +#[test] +fn cli_accepts_config_flag() { + // Just verify --config is accepted (it's a global flag) + cora_cmd() + .args(["--config", "/nonexistent/.cora.yaml", "--help"]) + .assert() + .success(); +} + +#[test] +fn cli_accepts_format_flag() { + cora_cmd() + .args(["--format", "json", "--help"]) + .assert() + .success(); +} + +#[test] +fn cli_accepts_provider_model_flags() { + cora_cmd() + .args(["--provider", "ollama", "--model", "llama3", "--help"]) + .assert() + .success(); +} + +#[test] +fn init_creates_cora_yaml() { + let tmp_dir = tempfile::tempdir().unwrap(); + + cora_cmd() + .args(["init"]) + .current_dir(tmp_dir.path()) + .assert() + .success(); + + let config_path = tmp_dir.path().join(".cora.yaml"); + assert!(config_path.exists(), ".cora.yaml should be created"); + + let content = std::fs::read_to_string(&config_path).unwrap(); + assert!( + content.contains("provider:"), + "should contain provider section" + ); +} + +#[test] +fn init_force_overwrites_existing() { + let tmp_dir = tempfile::tempdir().unwrap(); + let config_path = tmp_dir.path().join(".cora.yaml"); + + // Create existing file + let mut f = std::fs::File::create(&config_path).unwrap(); + writeln!(f, "# old config").unwrap(); + drop(f); + + cora_cmd() + .args(["init", "--force"]) + .current_dir(tmp_dir.path()) + .assert() + .success(); + + let content = std::fs::read_to_string(&config_path).unwrap(); + assert!( + !content.contains("# old config"), + "old content should be overwritten" + ); +} + +#[test] +fn init_without_force_fails_if_exists() { + let tmp_dir = tempfile::tempdir().unwrap(); + let config_path = tmp_dir.path().join(".cora.yaml"); + std::fs::write(&config_path, "# existing").unwrap(); + + cora_cmd() + .args(["init"]) + .current_dir(tmp_dir.path()) + .assert() + .failure() + .stderr(predicate::str::contains("already exists")); +} diff --git a/tests/config_loading.rs b/tests/config_loading.rs new file mode 100644 index 00000000..929352eb --- /dev/null +++ b/tests/config_loading.rs @@ -0,0 +1,147 @@ +use std::io::Write; + +use assert_cmd::prelude::*; +use std::process::Command; +use tempfile::NamedTempFile; + +fn cora_cmd() -> Command { + Command::cargo_bin("cora").unwrap() +} + +/// Helper: write content to a temp file and return its path. +fn write_temp_yaml(content: &str) -> NamedTempFile { + let mut file = tempfile::Builder::new().suffix(".yaml").tempfile().unwrap(); + write!(file, "{content}").unwrap(); + file.flush().unwrap(); + file +} + +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +// Config file parsing via init + manual verification +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +#[test] +fn init_creates_valid_yaml() { + let tmp_dir = tempfile::tempdir().unwrap(); + let config_path = tmp_dir.path().join(".cora.yaml"); + + cora_cmd() + .args(["init"]) + .current_dir(tmp_dir.path()) + .assert() + .success(); + + // Verify the created file is valid YAML + let content = std::fs::read_to_string(&config_path).unwrap(); + let parsed: serde_yaml_ng::Value = + serde_yaml_ng::from_str(&content).expect("init should create valid YAML"); + + // Verify it has expected top-level keys + assert!( + parsed.get("provider").is_some(), + "should have provider section" + ); + assert!(parsed.get("focus").is_some(), "should have focus section"); + assert!(parsed.get("output").is_some(), "should have output section"); +} + +#[test] +fn config_file_with_custom_provider() { + let yaml = r" +provider: + provider: anthropic + model: claude-3-haiku + base_url: https://api.anthropic.com/v1 +focus: + - security +output: + format: json +"; + let file = write_temp_yaml(yaml); + + // Parse and verify using serde_yaml_ng directly + let content = std::fs::read_to_string(file.path()).unwrap(); + let parsed: serde_yaml_ng::Value = serde_yaml_ng::from_str(&content).unwrap(); + + assert_eq!(parsed["provider"]["provider"].as_str(), Some("anthropic")); + assert_eq!(parsed["provider"]["model"].as_str(), Some("claude-3-haiku")); + assert_eq!(parsed["focus"].as_sequence().unwrap().len(), 1); + assert_eq!(parsed["output"]["format"].as_str(), Some("json")); +} + +#[test] +fn config_file_roundtrip_yaml() { + let yaml = r" +provider: + provider: ollama + model: llama3 + base_url: http://localhost:11434 +hook: + mode: block + min_severity: critical +ignore: + files: + - vendor/** + - generated/** +"; + let file = write_temp_yaml(yaml); + let content = std::fs::read_to_string(file.path()).unwrap(); + + // Parse to Value, serialize back + let val: serde_yaml_ng::Value = serde_yaml_ng::from_str(&content).unwrap(); + let reser = serde_yaml_ng::to_string(&val).unwrap(); + + // Re-parse the re-serialized version to ensure round-trip + let val2: serde_yaml_ng::Value = serde_yaml_ng::from_str(&reser).unwrap(); + assert_eq!(val2["provider"]["provider"], val["provider"]["provider"]); + assert_eq!(val2["hook"]["mode"], val["hook"]["mode"]); + assert_eq!( + val2["ignore"]["files"].as_sequence().unwrap().len(), + val["ignore"]["files"].as_sequence().unwrap().len() + ); +} + +#[test] +fn config_file_empty_yaml_is_valid() { + let yaml = ""; + let file = write_temp_yaml(yaml); + let content = std::fs::read_to_string(file.path()).unwrap(); + let parsed: serde_yaml_ng::Value = serde_yaml_ng::from_str(&content).unwrap(); + assert!(parsed.is_null()); +} + +#[test] +fn config_file_minimal_sections() { + let yaml = r" +focus: + - security +"; + let file = write_temp_yaml(yaml); + let content = std::fs::read_to_string(file.path()).unwrap(); + let parsed: serde_yaml_ng::Value = serde_yaml_ng::from_str(&content).unwrap(); + assert_eq!(parsed["focus"].as_sequence().unwrap().len(), 1); + assert!(parsed.get("provider").is_none()); +} + +#[test] +fn cli_uses_config_file_via_flag() { + // Create a temp config and verify the CLI at least accepts the flag + let yaml = r" +provider: + provider: test-provider +model: test-model +"; + let file = write_temp_yaml(yaml); + + // We can't run a full review without an API key, but we can verify + // the --config flag is accepted + cora_cmd() + .args([ + "--config", + &file.path().to_string_lossy(), + "review", + "--help", + ]) + .assert() + .success(); +} diff --git a/website/.gitignore b/website/.gitignore new file mode 100644 index 00000000..ce4f9d00 --- /dev/null +++ b/website/.gitignore @@ -0,0 +1,3 @@ +node_modules +.svelte-kit +build diff --git a/website/package-lock.json b/website/package-lock.json new file mode 100644 index 00000000..760f1319 --- /dev/null +++ b/website/package-lock.json @@ -0,0 +1,1600 @@ +{ + "name": "cora-website", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cora-website", + "version": "0.0.1", + "devDependencies": { + "@sveltejs/adapter-auto": "^7.0.1", + "@sveltejs/adapter-static": "^3.0.10", + "@sveltejs/kit": "^2.57.0", + "@sveltejs/vite-plugin-svelte": "^7.0.0", + "@tailwindcss/vite": "^4.3.0", + "svelte": "^5.55.2", + "svelte-check": "^4.4.6", + "tailwindcss": "^4.3.0", + "typescript": "^6.0.2", + "vite": "^8.0.7" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.132.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz", + "integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", + "integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz", + "integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz", + "integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz", + "integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz", + "integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz", + "integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz", + "integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz", + "integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz", + "integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz", + "integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz", + "integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz", + "integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz", + "integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz", + "integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz", + "integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.10.tgz", + "integrity": "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA==", + "dev": true, + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/adapter-auto": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-7.0.1.tgz", + "integrity": "sha512-dvuPm1E7M9NI/+canIQ6KKQDU2AkEefEZ2Dp7cY6uKoPq9Z/PhOXABe526UdW2mN986gjVkuSLkOYIBnS/M2LQ==", + "dev": true, + "peerDependencies": { + "@sveltejs/kit": "^2.0.0" + } + }, + "node_modules/@sveltejs/adapter-static": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.10.tgz", + "integrity": "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==", + "dev": true, + "peerDependencies": { + "@sveltejs/kit": "^2.0.0" + } + }, + "node_modules/@sveltejs/kit": { + "version": "2.61.1", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.61.1.tgz", + "integrity": "sha512-Ny8s1SR1TyQS2hD2Rvw0XKzU2Nw1eUF52dTb6T2bdcgz7wSC+Nyb5IwjWYlR4b2dvbbR5NJDiQwHg3rnNseghg==", + "dev": true, + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@sveltejs/acorn-typescript": "^1.0.9", + "@types/cookie": "^0.6.0", + "acorn": "^8.16.0", + "cookie": "^0.6.0", + "devalue": "^5.8.1", + "esm-env": "^1.2.2", + "kleur": "^4.1.5", + "magic-string": "^0.30.5", + "mrmime": "^2.0.0", + "set-cookie-parser": "^3.0.0", + "sirv": "^3.0.0" + }, + "bin": { + "svelte-kit": "svelte-kit.js" + }, + "engines": { + "node": ">=18.13" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.3.3 || ^6.0.0", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.1.2.tgz", + "integrity": "sha512-DrUBA2UXRfDmUX/ZTiEopd3X40yavsJF1FX2RygcuIScHL7o5YX1fMvoYnDhjeJQC4weCOklirpNWlcb2NiSeA==", + "dev": true, + "dependencies": { + "deepmerge": "^4.3.1", + "magic-string": "^0.30.21", + "obug": "^2.1.0", + "vitefu": "^1.1.2" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.46.4", + "vite": "^8.0.0-beta.7 || ^8.0.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", + "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", + "dev": true, + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.21.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", + "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", + "dev": true, + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-x64": "4.3.0", + "@tailwindcss/oxide-freebsd-x64": "4.3.0", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-x64-musl": "4.3.0", + "@tailwindcss/oxide-wasm32-wasi": "4.3.0", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz", + "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz", + "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz", + "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz", + "integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz", + "integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz", + "integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz", + "integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz", + "integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz", + "integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz", + "integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "optional": true, + "dependencies": { + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", + "integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz", + "integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.0.tgz", + "integrity": "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==", + "dev": true, + "dependencies": { + "@tailwindcss/node": "4.3.0", + "@tailwindcss/oxide": "4.3.0", + "tailwindcss": "4.3.0" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "dev": true + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", + "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", + "dev": true + }, + "node_modules/enhanced-resolve": { + "version": "5.22.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.1.tgz", + "integrity": "sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true + }, + "node_modules/esrap": { + "version": "2.2.9", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.9.tgz", + "integrity": "sha512-4KijP+NxCWthMCUC3qHbE6n4vCjqgJS1uAYKhuT/GWfFTf1Qyive2TgOjep+gzbSzRfnNyaN/UU9YmdOt8Eg0A==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ] + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rolldown": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", + "integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==", + "dev": true, + "dependencies": { + "@oxc-project/types": "=0.132.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.2", + "@rolldown/binding-darwin-arm64": "1.0.2", + "@rolldown/binding-darwin-x64": "1.0.2", + "@rolldown/binding-freebsd-x64": "1.0.2", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", + "@rolldown/binding-linux-arm64-gnu": "1.0.2", + "@rolldown/binding-linux-arm64-musl": "1.0.2", + "@rolldown/binding-linux-ppc64-gnu": "1.0.2", + "@rolldown/binding-linux-s390x-gnu": "1.0.2", + "@rolldown/binding-linux-x64-gnu": "1.0.2", + "@rolldown/binding-linux-x64-musl": "1.0.2", + "@rolldown/binding-openharmony-arm64": "1.0.2", + "@rolldown/binding-wasm32-wasi": "1.0.2", + "@rolldown/binding-win32-arm64-msvc": "1.0.2", + "@rolldown/binding-win32-x64-msvc": "1.0.2" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/set-cookie-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", + "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==", + "dev": true + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/svelte": { + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.0.tgz", + "integrity": "sha512-kTXr26t1bchFp28ROrb957LtbujpBmBDibmqMGziVpUs7awBi96TGgX6SovrA8BNoEUDVRK2Fb9FkeYlGspoVg==", + "dev": true, + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.10", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.8.1", + "esm-env": "^1.2.1", + "esrap": "^2.2.9", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.4.8", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.4.8.tgz", + "integrity": "sha512-67adfgBox5eNSNIvIIwgFizKGdcRrGpiMoNO2obHcYuLz7iTa8Xgm/NGU3ntMFnNm8K1grFOIG6HhMLX/vcN8w==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": ">=5.0.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", + "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", + "dev": true + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "optional": true + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "8.0.14", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", + "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", + "dev": true, + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.2", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true + } + } +} diff --git a/website/package.json b/website/package.json new file mode 100644 index 00000000..57997c5e --- /dev/null +++ b/website/package.json @@ -0,0 +1,25 @@ +{ + "name": "cora-website", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json" + }, + "devDependencies": { + "@sveltejs/adapter-auto": "^7.0.1", + "@sveltejs/adapter-static": "^3.0.10", + "@sveltejs/kit": "^2.57.0", + "@sveltejs/vite-plugin-svelte": "^7.0.0", + "@tailwindcss/vite": "^4.3.0", + "svelte": "^5.55.2", + "svelte-check": "^4.4.6", + "tailwindcss": "^4.3.0", + "typescript": "^6.0.2", + "vite": "^8.0.7" + } +} diff --git a/website/src/app.css b/website/src/app.css new file mode 100644 index 00000000..3f8ec322 --- /dev/null +++ b/website/src/app.css @@ -0,0 +1,894 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap'); +@import 'tailwindcss'; + +/* ============================================ + DESIGN SYSTEM โ€” cora CLI + 8px grid ยท Linear-inspired dark theme + Spacing reference: Linear, Supabase, Vercel + ============================================ */ + +/* ---- CSS Custom Properties (oklch color space) ---- */ +:root { + --background: oklch(0.13 0.01 270); + --foreground: oklch(0.92 0.01 270); + --card: oklch(0.17 0.01 270); + --card-foreground: oklch(0.92 0.01 270); + --muted: oklch(0.22 0.01 270); + --muted-foreground: oklch(0.65 0.01 270); + --accent: oklch(0.65 0.22 270); + --accent-foreground: oklch(0.98 0 0); + --success: oklch(0.72 0.19 145); + --warning: oklch(0.78 0.15 85); + --destructive: oklch(0.55 0.22 25); + --border: oklch(0.27 0.01 270); + --ring: oklch(0.65 0.22 270); + --shadow-card: 0 1px 3px oklch(0 0 0 / 0.3); + --shadow-card-hover: 0 4px 12px oklch(0 0 0 / 0.4); + + /* Font families */ + --font-sans: 'Inter', system-ui, -apple-system, sans-serif; + --font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace; + + /* ---- 8px Spacing Scale ---- */ + --space-0: 0; + --space-1: 0.25rem; /* 4px */ + --space-2: 0.5rem; /* 8px */ + --space-3: 0.75rem; /* 12px */ + --space-4: 1rem; /* 16px */ + --space-5: 1.25rem; /* 20px */ + --space-6: 1.5rem; /* 24px */ + --space-8: 2rem; /* 32px */ + --space-10: 2.5rem; /* 40px */ + --space-12: 3rem; /* 48px */ + --space-16: 4rem; /* 64px */ + --space-20: 5rem; /* 80px */ + --space-24: 6rem; /* 96px */ + --space-32: 8rem; /* 128px */ +} + +/* ---- Tailwind v4 Theme Tokens ---- */ +@theme { + --color-background: oklch(0.13 0.01 270); + --color-foreground: oklch(0.92 0.01 270); + --color-card: oklch(0.17 0.01 270); + --color-card-foreground: oklch(0.92 0.01 270); + --color-muted: oklch(0.22 0.01 270); + --color-muted-muted-foreground: oklch(0.65 0.01 270); + --color-accent: oklch(0.65 0.22 270); + --color-accent-foreground: oklch(0.98 0 0); + --color-success: oklch(0.72 0.19 145); + --color-warning: oklch(0.78 0.15 85); + --color-destructive: oklch(0.55 0.22 25); + --color-border: oklch(0.27 0.01 270); + --color-ring: oklch(0.65 0.22 270); + --font-family-sans: 'Inter', system-ui, -apple-system, sans-serif; + --font-family-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace; +} + +/* ---- Base Reset ---- */ +*, +*::before, +*::after { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html { + background-color: var(--background); + color: var(--foreground); + font-family: var(--font-sans); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; + scroll-behavior: smooth; +} + +body { + min-height: 100vh; + line-height: 1.5; + overflow-x: hidden; +} + +/* ---- Selection ---- */ +::selection { + background: oklch(0.65 0.22 270 / 0.3); + color: var(--accent-foreground); +} + +/* ---- Scrollbar ---- */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: var(--muted); +} + +::-webkit-scrollbar-thumb { + background: var(--muted-foreground); + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: oklch(0.75 0.01 270); +} + +/* ---- Animations (motion OK) ---- */ +@media (prefers-reduced-motion: no-preference) { + @keyframes fade-in-up { + from { + opacity: 0; + transform: translateY(12px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + + @keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } + } + + @keyframes typing-cursor { + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } + } + + @keyframes subtle-float { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-4px); } + } + + @keyframes glow { + 0%, 100% { + box-shadow: 0 0 8px oklch(0.65 0.22 270 / 0.15), + 0 0 20px oklch(0.65 0.22 270 / 0.05); + } + 50% { + box-shadow: 0 0 12px oklch(0.65 0.22 270 / 0.25), + 0 0 30px oklch(0.65 0.22 270 / 0.1); + } + } + + .animate-fade-in-up { + animation: fade-in-up 0.5s ease-out forwards; + } + + .animate-fade-in { + animation: fade-in 0.3s ease-out forwards; + } + + .delay-100 { animation-delay: 100ms; opacity: 0; } + .delay-200 { animation-delay: 200ms; opacity: 0; } + .delay-300 { animation-delay: 300ms; opacity: 0; } + .delay-400 { animation-delay: 400ms; opacity: 0; } + .delay-500 { animation-delay: 500ms; opacity: 0; } +} + +/* ---- Reduced Motion: disable ALL ---- */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + transform: none !important; + } + html { + scroll-behavior: auto; + } + .scroll-reveal { + opacity: 1 !important; + transform: none !important; + } + .animate-fade-in-up, + .animate-fade-in { + opacity: 1 !important; + transform: none !important; + } + .delay-100, .delay-200, .delay-300, .delay-400, .delay-500 { + opacity: 1 !important; + } +} + +/* ---- Scroll Reveal ---- */ +.scroll-reveal { + opacity: 0; + transform: translateY(12px); + transition: opacity 0.5s ease-out, transform 0.5s ease-out; +} + +.scroll-reveal.revealed { + opacity: 1; + transform: translateY(0); +} + +/* ---- Glass Card ---- */ +.glass-card { + background: var(--card); + border-radius: var(--space-4); + padding: var(--space-6); + box-shadow: var(--shadow-card); + border: 1px solid oklch(0.27 0.01 270 / 0.5); + transition: box-shadow 0.3s ease-out, transform 0.3s ease-out; +} + +.glass-card:hover { + box-shadow: var(--shadow-card-hover); + transform: translateY(-2px); +} + +/* ---- Terminal ---- */ +.terminal { + background: oklch(0.10 0 0); + border-radius: var(--space-3); + border: 1px solid var(--border); + overflow: hidden; + font-family: var(--font-mono); +} + +.terminal-header { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-3) var(--space-4); + border-bottom: 1px solid oklch(0.27 0.01 270 / 0.5); + position: relative; +} + +.terminal-dot { + width: var(--space-3); + height: var(--space-3); + border-radius: 9999px; + flex-shrink: 0; +} + +.terminal-dot-red { background: var(--destructive); } +.terminal-dot-yellow { background: var(--warning); } +.terminal-dot-green { background: var(--success); } + +.terminal-title { + font-family: var(--font-mono); + font-size: 12px; + color: var(--muted-foreground); + margin-left: var(--space-2); +} + +.terminal-body { + padding: var(--space-5); + font-family: var(--font-mono); + font-size: 14px; + line-height: 1.625; + color: var(--foreground); +} + +/* ---- Buttons ---- */ +.btn-primary { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + background: var(--accent); + color: var(--accent-foreground); + border-radius: var(--space-2); + padding: var(--space-2) var(--space-5); + font-weight: 500; + font-size: 14px; + font-family: var(--font-sans); + min-height: 44px; + border: none; + cursor: pointer; + text-decoration: none; + transition: opacity 0.2s ease; +} + +.btn-primary:hover { + opacity: 0.9; +} + +.btn-primary:active { + opacity: 0.8; +} + +.btn-ghost { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + background: transparent; + color: var(--foreground); + border: 1px solid var(--border); + border-radius: var(--space-2); + padding: var(--space-2) var(--space-5); + font-weight: 500; + font-size: 14px; + font-family: var(--font-sans); + min-height: 44px; + cursor: pointer; + text-decoration: none; + transition: background-color 0.2s ease, border-color 0.2s ease; +} + +.btn-ghost:hover { + background: var(--muted); + border-color: oklch(0.35 0.01 270); +} + +.btn-ghost:active { + background: oklch(0.25 0.01 270); +} + +/* ---- Section Container (8px grid based) ---- */ +.section { + max-width: 1152px; /* 72 ร— 16px */ + margin-left: auto; + margin-right: auto; + padding-left: var(--space-6); /* 24px mobile */ + padding-right: var(--space-6); /* 24px mobile */ +} + +@media (min-width: 768px) { + .section { + padding-left: var(--space-8); /* 32px tablet */ + padding-right: var(--space-8); + } +} + +@media (min-width: 1280px) { + .section { + padding-left: var(--space-10); /* 40px desktop */ + padding-right: var(--space-10); + } +} + +/* Section vertical rhythm โ€” based on Linear (96/128) + Supabase (64/72/96) */ +.section-hero { + padding-top: var(--space-20); /* 80px mobile */ + padding-bottom: var(--space-16); /* 64px mobile */ +} + +.section-tall { + padding-top: var(--space-16); /* 64px mobile */ + padding-bottom: var(--space-16); /* 64px mobile */ +} + +.section-compact { + padding-top: var(--space-12); /* 48px mobile */ + padding-bottom: var(--space-12); /* 48px mobile */ +} + +@media (min-width: 768px) { + .section-hero { + padding-top: var(--space-24); /* 96px tablet+ */ + padding-bottom: var(--space-20); /* 80px tablet+ */ + } + .section-tall { + padding-top: var(--space-24); /* 96px tablet+ */ + padding-bottom: var(--space-24); /* 96px tablet+ */ + } + .section-compact { + padding-top: var(--space-16); /* 64px tablet+ */ + padding-bottom: var(--space-16); /* 64px tablet+ */ + } +} + +@media (min-width: 1280px) { + .section-hero { + padding-top: var(--space-32); /* 128px desktop */ + padding-bottom: var(--space-24); /* 96px desktop */ + } + .section-tall { + padding-top: var(--space-32); /* 128px desktop */ + padding-bottom: var(--space-32); /* 128px desktop */ + } +} + +/* section-tall and section-compact mobile handled by min-width queries above */ + +/* ---- Badge ---- */ +.badge { + display: inline-flex; + align-items: center; + gap: var(--space-1); + font-size: 12px; + font-weight: 500; + background: var(--muted); + color: var(--muted-foreground); + border-radius: 9999px; + padding: var(--space-1) var(--space-2); + line-height: 1.5; + letter-spacing: 0.01em; +} + +.badge-dot { + width: 6px; + height: 6px; + border-radius: 9999px; + background: var(--success); +} + +.accent-badge { + display: inline-flex; + align-items: center; + gap: var(--space-1); + font-size: 12px; + font-weight: 500; + background: oklch(0.65 0.22 270 / 0.15); + color: var(--accent); + border-radius: 9999px; + padding: var(--space-1) var(--space-2); + line-height: 1.5; + letter-spacing: 0.01em; +} + +/* ---- Typing Cursor ---- */ +.typing-cursor { + display: inline-block; + width: 8px; + height: 1.1em; + background: var(--accent); + margin-left: 2px; + vertical-align: text-bottom; + animation: typing-cursor 1s step-end infinite; +} + +/* ---- Copy Button ---- */ +.copy-btn { + position: absolute; + top: var(--space-3); + right: var(--space-3); + display: flex; + align-items: center; + gap: var(--space-1); + padding: var(--space-1) var(--space-2); + background: var(--muted); + border: 1px solid var(--border); + border-radius: var(--space-1); + color: var(--muted-foreground); + font-size: 12px; + font-family: var(--font-sans); + cursor: pointer; + transition: background-color 0.2s ease, color 0.2s ease; + min-height: 44px; + min-width: 44px; + justify-content: center; +} + +.copy-btn:hover { + background: oklch(0.28 0.01 270); + color: var(--foreground); +} + +.copy-btn.copied { + color: var(--success); + border-color: oklch(0.72 0.19 145 / 0.3); +} + +/* ---- Compare Table ---- */ +.compare-table { + width: 100%; + border-collapse: collapse; +} + +.compare-table th, +.compare-table td { + padding: var(--space-3) var(--space-5); + border-bottom: 1px solid oklch(0.27 0.01 270 / 0.5); + text-align: left; + font-size: 14px; + color: var(--foreground); +} + +.compare-table thead th { + color: var(--muted-foreground); + font-weight: 500; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.05em; + padding-bottom: var(--space-3); +} + +.compare-table tbody tr:last-child td { + border-bottom: none; +} + +.compare-table .cora-col { + background: oklch(0.65 0.22 270 / 0.05); + font-weight: 500; +} + +.compare-table thead .cora-col { + background: oklch(0.65 0.22 270 / 0.1); + color: var(--accent); +} + +/* Compare table mobile handled by min-width queries in section container */ + +/* ---- Syntax Highlighting ---- */ +.syntax-cmd { color: var(--muted-foreground); } +.syntax-flag { color: oklch(0.75 0.15 240); } +.syntax-string { color: var(--success); } +.syntax-comment { color: var(--muted-foreground); } +.syntax-highlight { color: var(--accent); } +.syntax-success { color: var(--success); } +.syntax-warning { color: var(--warning); } +.syntax-error { color: var(--destructive); } + +/* ---- Check/X Symbols ---- */ +.symbol-check { + color: var(--success); + font-weight: 600; +} + +.symbol-cross { + color: var(--muted-foreground); + font-weight: 600; +} + +/* ---- Connect Line (How It Works) ---- */ +.connect-line { + position: relative; + flex: 1; + display: flex; + align-items: center; + justify-content: center; + min-width: var(--space-10); +} + +.connect-line::after { + content: ''; + display: block; + width: 100%; + max-width: var(--space-10); + height: 1px; + background: linear-gradient( + 90deg, + transparent, + oklch(0.65 0.22 270 / 0.4), + transparent + ); +} + +/* ---- Feature Icon ---- */ +.feature-icon { + width: var(--space-12); + height: var(--space-12); + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--space-3); + background: oklch(0.65 0.22 270 / 0.1); +} + +.feature-icon svg { + width: var(--space-6); + height: var(--space-6); + color: var(--accent); +} + +/* ---- Timeline Step ---- */ +.timeline-step { + display: flex; + gap: var(--space-5); + align-items: flex-start; +} + +.timeline-number { + width: var(--space-10); + height: var(--space-10); + display: flex; + align-items: center; + justify-content: center; + border-radius: 9999px; + background: oklch(0.65 0.22 270 / 0.15); + color: var(--accent); + font-weight: 700; + font-size: 14px; + flex-shrink: 0; +} + +/* ---- Docs Layout ---- */ +.docs-sidebar { + position: sticky; + top: 3.5rem; + height: calc(100vh - 3.5rem); + overflow-y: auto; + width: 16rem; + flex-shrink: 0; + padding-top: var(--space-8); + border-right: 1px solid var(--border); +} + +.docs-sidebar-link { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + border-radius: var(--space-2); + color: var(--muted-foreground); + font-size: 14px; + text-decoration: none; + transition: background-color 0.15s ease, color 0.15s ease; + min-height: 44px; +} + +.docs-sidebar-link:hover { + background: var(--muted); + color: var(--foreground); +} + +.docs-sidebar-link.active { + background: oklch(0.65 0.22 270 / 0.1); + color: var(--accent); +} + +.docs-content { + flex: 1; + min-width: 0; + padding: var(--space-8) var(--space-8) var(--space-16); +} + +.docs-content h1 { + font-size: 2rem; + font-weight: 700; + letter-spacing: -0.025em; + line-height: 1.2; + margin-bottom: var(--space-4); + color: var(--foreground); +} + +.docs-content h2 { + font-size: 1.375rem; + font-weight: 600; + letter-spacing: -0.025em; + line-height: 1.3; + margin-top: var(--space-10); + margin-bottom: var(--space-4); + color: var(--foreground); +} + +.docs-content h3 { + font-size: 1.125rem; + font-weight: 600; + letter-spacing: -0.025em; + line-height: 1.4; + margin-top: var(--space-8); + margin-bottom: var(--space-3); + color: var(--foreground); +} + +.docs-content p { + color: var(--muted-foreground); + font-size: 0.9375rem; + line-height: 1.7; + margin-bottom: var(--space-4); +} + +.docs-content ul, .docs-content ol { + color: var(--muted-foreground); + font-size: 0.9375rem; + line-height: 1.7; + padding-left: var(--space-6); + margin-bottom: var(--space-4); +} + +.docs-content li { + margin-bottom: var(--space-2); +} + +.docs-content code:not(pre code) { + background: var(--muted); + color: var(--accent); + padding: 0.125rem 0.375rem; + border-radius: 0.25rem; + font-size: 0.8125rem; +} + +.docs-content pre { + background: oklch(0.10 0 0); + border: 1px solid var(--border); + border-radius: var(--space-3); + padding: var(--space-5); + overflow-x: auto; + margin-bottom: var(--space-6); +} + +.docs-content pre code { + font-family: var(--font-mono); + font-size: 0.8125rem; + line-height: 1.625; + color: var(--foreground); +} + +.docs-content a { + color: var(--accent); + text-decoration: none; + transition: opacity 0.2s ease; +} + +.docs-content a:hover { + opacity: 0.8; + text-decoration: underline; +} + +/* ---- Docs Section ---- */ +.docs-section { + margin-top: var(--space-12); +} + +.docs-section h2 { + font-size: 1.375rem; + font-weight: 600; + letter-spacing: -0.025em; + line-height: 1.3; + margin-bottom: var(--space-3); + color: var(--foreground); +} + +.docs-section p { + color: var(--muted-foreground); + font-size: 0.9375rem; + line-height: 1.7; + margin-bottom: var(--space-4); +} + +/* ---- Docs Card ---- */ +.docs-card { + display: flex; + align-items: center; + gap: var(--space-4); + padding: var(--space-4); + background: var(--card); + border-radius: var(--space-3); + border: 1px solid var(--border); +} + +.docs-card-number { + width: var(--space-8); + height: var(--space-8); + display: flex; + align-items: center; + justify-content: center; + border-radius: 9999px; + background: oklch(0.65 0.22 270 / 0.1); + color: var(--accent); + font-weight: 700; + font-size: 14px; + flex-shrink: 0; +} + +.docs-card-number.primary { + background: oklch(0.65 0.22 270 / 0.15); + color: var(--accent); +} + +.docs-card-number.muted { + background: var(--muted); + color: var(--muted-foreground); +} + +/* ---- Docs Terminal ---- */ +.docs-terminal { + background: oklch(0.10 0 0); + border-radius: var(--space-3); + border: 1px solid var(--border); + overflow: hidden; + margin-bottom: var(--space-6); +} + +.terminal-bar { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-3) var(--space-4); + border-bottom: 1px solid oklch(0.27 0.01 270 / 0.5); + background: oklch(0.10 0 0); +} + +.terminal-bar span { + width: var(--space-2); + height: var(--space-2); + border-radius: 9999px; +} + +.terminal-bar .terminal-dot-red { background: var(--destructive); } +.terminal-bar .terminal-dot-yellow { background: var(--warning); } +.terminal-bar .terminal-dot-green { background: var(--success); } + +/* ---- Docs utility classes ---- */ +.docs-term-list { + display: flex; + flex-direction: column; + gap: var(--space-2); + margin-bottom: var(--space-4); +} + +.docs-term-item { + display: flex; + gap: var(--space-2); + font-size: 14px; + color: var(--muted-foreground); +} + +.docs-term-key { + color: var(--accent); + font-family: var(--font-mono); + flex-shrink: 0; +} + +.docs-term-value { + color: var(--muted-foreground); + font-size: 14px; +} + +.docs-link-list { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.docs-link-list a { + font-size: 14px; + color: var(--accent); + text-decoration: none; +} + +.docs-link-list a:hover { + text-decoration: underline; +} + +/* ---- Mobile docs nav ---- */ +.mobile-docs-nav { + display: none; + position: sticky; + top: 3.5rem; + z-index: 20; + background: var(--background); + border-bottom: 1px solid var(--border); + padding: var(--space-2) var(--space-4); + overflow-x: auto; + white-space: nowrap; +} + +.mobile-docs-nav a { + display: inline-block; + padding: var(--space-2) var(--space-3); + border-radius: var(--space-2); + color: var(--muted-foreground); + font-size: 14px; + text-decoration: none; + min-height: 44px; + line-height: 1.25; + transition: background-color 0.15s ease, color 0.15s ease; +} + +.mobile-docs-nav a:hover { + background: var(--muted); + color: var(--foreground); +} + +.mobile-docs-nav a.active { + background: oklch(0.65 0.22 270 / 0.1); + color: var(--accent); +} + +@media (min-width: 768px) { + .mobile-docs-nav { + display: none; + } +} + +/* ---- Mobile: collapse docs sidebar ---- */ +@media (max-width: 767px) { + .docs-sidebar { + display: none; + } + .docs-content { + padding: var(--space-6) var(--space-4) var(--space-12); + } +} diff --git a/website/src/app.d.ts b/website/src/app.d.ts new file mode 100644 index 00000000..7d72385c --- /dev/null +++ b/website/src/app.d.ts @@ -0,0 +1,19 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +// Allow ?raw imports for markdown files +declare module '*?raw' { + const content: string; + export default content; +} + +export {}; diff --git a/website/src/app.html b/website/src/app.html new file mode 100644 index 00000000..e84d4eda --- /dev/null +++ b/website/src/app.html @@ -0,0 +1,30 @@ + + + + + + + + + + cora โ€” AI Code Review CLI + + + + + + + + + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/website/src/routes/+layout.svelte b/website/src/routes/+layout.svelte new file mode 100644 index 00000000..d4dbb27a --- /dev/null +++ b/website/src/routes/+layout.svelte @@ -0,0 +1,40 @@ + + +
+ + + +
+ {@render children()} +
+
diff --git a/website/src/routes/+layout.ts b/website/src/routes/+layout.ts new file mode 100644 index 00000000..ceccaaf6 --- /dev/null +++ b/website/src/routes/+layout.ts @@ -0,0 +1,2 @@ +export const prerender = true; +export const ssr = false; diff --git a/website/src/routes/+page.svelte b/website/src/routes/+page.svelte new file mode 100644 index 00000000..c0dfc5b6 --- /dev/null +++ b/website/src/routes/+page.svelte @@ -0,0 +1,548 @@ + + + + cora โ€” AI Code Review CLI + + + +
+ + +
+ +
+ +
+ +
+ + + AI Code Review for Developers + +
+ + +

+ Review code.
+ Ship faster. +

+ + +

+ cora catches bugs, security issues, and style violations before they merge. CLI-first. BYOK. Runs in your terminal. +

+ + +
+
+
+ + + + Terminal +
+
+ $ + cargo install + cora + + +
+
+
+ + + + + +

+ MIT License · No account · OpenAI · Anthropic · Groq · Ollama +

+
+
+ + +
+

+ Trusted by developers who ship fast +

+
+
+
5
+
AI Providers
+
+
+
< 3s
+
Review Time
+
+
+
Zero
+
Config Required
+
+
+
+ + +
+

+ See it in action +

+

+ Run cora against staged changes. Results in seconds, not minutes. +

+ +
+
+
+ + + + cora \u2014 review +
+
+ {#each terminalLines as line, i} +
{line}
+ {/each} + {#if terminalComplete} + + {/if} +
+
+
+
+ + +
+

+ How it works +

+

+ Three steps from code to confidence. +

+ +
+ +
+
01
+
+ +
+

Write code

+

Push your changes as normal. cora only sees your diff.

+
+ + + + + +
+
02
+
+ +
+

Review with AI

+

cora analyzes your diff with the LLM of your choice.

+
+ + + + + +
+
03
+
+ +
+

Ship with confidence

+

Merge clean, production-ready code. Every time.

+
+
+
+ + +
+

+ Built for developers who value control +

+

+ Everything you need, nothing you don't. +

+ +
+ +
+
+ +
+

AI Code Review

+

Diff, branch, or full scan

+

Three review modes: staged diff, branch comparison, or full project scan. LLM-powered analysis catches bugs, security issues, and style violations.

+
+ + +
+
+ +
+

Bring Your Own Key

+

No subscriptions, no lock-in

+

Uses YOUR OpenAI, Anthropic, Groq, Ollama, or Z.AI API key. No data stored on our servers. You control the model, you control the cost.

+
+ + +
+
+ +
+

Pre-commit Hooks

+

Review before you push

+

Install once. Every commit gets reviewed automatically. Block bad code from entering your branch before it ships.

+
+ + +
+
+ +
+

Incremental Scan

+

Only scan what changed

+

SHA256 content hash cache. First scan indexes your codebase. Subsequent scans only review new or modified files.

+
+ + +
+
+ +
+

SARIF Output

+

GitHub Code Scanning

+

Upload review findings directly to GitHub's Security tab. Track issues across PRs. Works with any CI/CD pipeline.

+
+ + +
+
+ +
+

Fully Private

+

Your code stays yours

+

Runs entirely on your machine. No cloud, no telemetry, no data leaving your network. Perfect for Gitea and air-gapped environments.

+
+ + +
+
+ +
+

Deterministic Reviews

+

Same diff, same issues

+

Temperature defaults to 0. Identical diffs always produce identical findings โ€” perfect for CI reproducibility.

+
+ + +
+
+ +
+

Smart Caching

+

Never review the same diff twice

+

Reviews are cached by diff hash in ~/.cache/cora/reviews/. Re-reviewing an unchanged diff returns cached results instantly. Use --no-cache to bypass.

+
+ + +
+
+ +
+

Custom Prompts & Anti-Hallucination

+

Control the review, trust the output

+

Override system prompts for review and scan. File path injection and post-parse filtering ensure the LLM only reports issues that exist in your actual diff.

+
+
+
+ + +
+

+ Why developers choose cora +

+

+ Side-by-side with popular code review tools. +

+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FeaturecoraCodeRabbitCopilotSonarQube
BYOK✓✗✗—
Self-hosted✓✗✗✓
Gitea✓✗✗✓
CLI✓✗✗✗
Pre-commit✓✗✗✗
SARIF✓✓✓✓
CostFree + API$12-39/mo$10-39/moFree / $150+
LicenseMITApache 2.0ProprietaryLGPL
+
+
+
+ + +
+

+ Start in 30 seconds +

+

+ No account required. No subscription. No cloud. +

+ +
+ +
+
1
+
+

Install

+

Single binary, no dependencies.

+
+
+ + + +
+
+ $ cargo install cora +
+
+
+
+ + +
+
2
+
+

Initialize

+

Creates .cora.yaml config file.

+
+
+ + + +
+
+ $ cora init +
+
+
+
+ + +
+
3
+
+

Review

+

Review your staged changes.

+
+
+ + + +
+
+ $ CORA_API_KEY=key cora review --staged +
+
+
+
+ + +
+
4
+
+

Done

+

That's it. No account. No subscription.

+
+
+
+ + +
+

+ Ready to ship better code? +

+

+ No account. No subscription. No cloud. +

+ +
+ + +
+
+
+ cora + MIT License +
+
+ GitHub + Docs +
+
+
+
+
diff --git a/website/src/routes/docs/+layout.svelte b/website/src/routes/docs/+layout.svelte new file mode 100644 index 00000000..6448fd74 --- /dev/null +++ b/website/src/routes/docs/+layout.svelte @@ -0,0 +1,68 @@ + + + + Docs โ€” cora + + +
+ + + + + + + + +
+
+ {@render children()} +
+
+
diff --git a/website/src/routes/docs/+page.svelte b/website/src/routes/docs/+page.svelte new file mode 100644 index 00000000..dde29ff7 --- /dev/null +++ b/website/src/routes/docs/+page.svelte @@ -0,0 +1,5 @@ + diff --git a/website/src/routes/docs/changelog/+page.svelte b/website/src/routes/docs/changelog/+page.svelte new file mode 100644 index 00000000..1811adc6 --- /dev/null +++ b/website/src/routes/docs/changelog/+page.svelte @@ -0,0 +1,113 @@ + + + + Changelog โ€” cora docs + + + +
+

Changelog

+

+ For the full changelog, see the + repository. +

+ + {#each releases as release, i} +
+

+ {#if release.version === 'Unreleased'} + + Unreleased + + {:else} + + v{release.version} + + {/if} + {release.date} +

+ + {#each release.categories as category} +
+

{category.type}

+
    + {#each category.items as item} +
  • {@html item}
  • + {/each} +
+
+ {/each} +
+ {/each} +
diff --git a/website/src/routes/docs/cli-reference/+page.svelte b/website/src/routes/docs/cli-reference/+page.svelte new file mode 100644 index 00000000..d5d48140 --- /dev/null +++ b/website/src/routes/docs/cli-reference/+page.svelte @@ -0,0 +1,242 @@ + + + + CLI Reference โ€” cora docs + + + +
+

CLI Reference

+

Complete command reference for the cora CLI.

+ + +
+

+ + Global Flags +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FlagDescription
--config <path>Override config file path (default: .cora.yaml)
--format <fmt>Output format: pretty, json, compact, sarif
--no-colorDisable colored output
--provider <name>Override provider
--model <name>Override model
--base-url <url>Override API base URL
--api-key <key>Override API key
--verboseEnable debug logging
+
+
+ + +
+

+ + Commands +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CommandDescription
cora initCreate .cora.yaml config file
cora reviewReview code changes (default: staged files)
cora review --stagedReview staged git changes explicitly
cora review --unstagedReview unstaged working changes
cora review --unpushedReview unpushed commits
cora review --base <branch>Compare current branch against target
cora review --commit <ref>Review specific commit or range
cora review --diff-file <path>Review from a diff file
cora review --uploadReview and upload SARIF to GitHub Code Scanning
cora scan <path>Scan files for issues
cora scan . [--incremental]Scan only changed files
cora config showShow resolved configuration
cora config set <key> <value>Set a config value
cora hook installInstall pre-commit hook
cora hook uninstallRemove pre-commit hook
cora auth loginSave API key to ~/.cora/config.toml
cora auth statusCheck current auth status
cora auth removeRemove stored API key
cora providersList detected AI providers
cora upload-sarif <file>Upload SARIF to GitHub Code Scanning
cora completion <shell>Generate shell completions (bash/zsh/fish)
+
+
+ + +
+

+ + Quick Examples +

+
+
+
+ + + +
+
+ # Review staged changes (what's about to be committed)
+ $ cora review --staged +
+
+ +
+
+ + + +
+
+ # Compare your feature branch against main
+ $ cora review --base main +
+
+ +
+
+ + + +
+
+ # Full project scan with incremental caching
+ $ cora scan --incremental +
+
+ +
+
+ + + +
+
+ # Install pre-commit hook
+ $ cora hook install +
+
+
+
+
diff --git a/website/src/routes/docs/configuration/+page.svelte b/website/src/routes/docs/configuration/+page.svelte new file mode 100644 index 00000000..fee65771 --- /dev/null +++ b/website/src/routes/docs/configuration/+page.svelte @@ -0,0 +1,292 @@ + + + + Configuration โ€” cora docs + + + +
+

Configuration

+

cora uses a layered config system. Later sources override earlier ones.

+ + +
+

+ + Config Resolution Order +

+

Settings are resolved in this order (highest priority first):

+
+ {#each [ + { num: '1', label: 'CLI flags', desc: '--provider, --model, --base-url, etc.', accent: true }, + { num: '2', label: 'Environment variables', desc: 'CORA_API_KEY, CORA_PROVIDER, CORA_MODEL, etc.', accent: false }, + { num: '3', label: '.cora.yaml', desc: 'Project root config file', accent: false }, + { num: '4', label: '~/.cora/config.yaml', desc: 'Global config (optional)', accent: false }, + { num: '5', label: 'Built-in defaults', desc: 'Sensible defaults for all settings', accent: false } + ] as item} +
+
{item.num}
+
+
{item.label}
+
{item.desc}
+
+
+ {/each} +
+
+ + +
+

+ + .cora.yaml Example +

+

Create this file in your project root. Run cora init to generate it.

+
+
+ + + + .cora.yaml +
+
+
# cora project config
+provider:
+  provider: openai
+  model: gpt-4o
+  base_url: https://api.openai.com/v1
+
+llm:
+  temperature: 0
+  max_tokens: 4096
+  timeout: 120
+  cache_ttl: 1440
+
+review:
+  system_prompt: "You are a senior code reviewer."
+  # system_prompt_file: ./review-prompt.md
+  response_format: json_object
+
+focus: security, performance, bugs
+
+hook:
+  mode: warn
+  min_severity: major
+  max_diff_size: 51200
+
+ignore:
+  files:
+    - "vendor/**"
+    - "*.min.js"
+
+
+
+ + +
+

+ + Environment Variables +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
VariableDescription
CORA_API_KEYAPI key for the active provider
CORA_PROVIDERActive provider (openai, anthropic, groq, ollama, zai)
CORA_MODELModel name override
CORA_BASE_URLCustom API base URL
CORA_CONFIGPath to config file
CORA_FORMATOutput format (pretty, json, compact, sarif)
CORA_NO_COLORDisable colored output
CORA_NO_CACHESkip diff-hash review cache (same as --no-cache)
GITHUB_TOKENGitHub token for SARIF upload
GITHUB_REPOSITORYGitHub repo for SARIF upload
GITHUB_REFGitHub ref for SARIF upload
+
+
+ + +
+

+ + Provider-Specific Env Vars +

+

Each provider has its own API key variable. cora checks these for auto-detection.

+
+
+ + + + env vars +
+
+
# OpenAI
+OPENAI_API_KEY=sk-...
+OPENAI_BASE_URL=https://api.openai.com/v1
+
+# Anthropic
+ANTHROPIC_API_KEY=sk-ant-...
+
+# Groq
+GROQ_API_KEY=gsk_...
+
+# Ollama (local, no key needed)
+OLLAMA_HOST=http://localhost:11434
+# Optional: OLLAMA_API_KEY if your Ollama instance requires auth
+OLLAMA_API_KEY=...
+
+# Z.AI
+ZAI_API_KEY=...
+
+
+
+ + +
+

+ + Diff-Hash Caching +

+

cora caches review results by diff hash in ~/.cache/cora/reviews/. If you re-review the same diff, the cached result is returned instantly.

+
+
+
+
Config
+
+ llm.cache_ttl โ€” TTL in minutes (default: 1440 / 24h) +
+
+
+
CLI / Env
+
+ --no-cache or CORA_NO_CACHE=1 to bypass +
+
+
+
+
+ + +
+

+ + Custom System Prompts +

+

Override the default system prompt for review or scan commands to match your project's coding standards and review criteria.

+
+
+ + + + .cora.yaml +
+
+
review:
+  system_prompt: "Focus on Rust idioms and error handling."
+  # Or load from a file:
+  system_prompt_file: ./prompts/review.md
+
+scan:
+  system_prompt: "Check for OWASP Top 10 vulnerabilities."
+  system_prompt_file: ./prompts/scan.md
+
+
+

If both system_prompt and system_prompt_file are set, the file takes precedence.

+
+ + +
+

+ + Response Format (JSON Mode) +

+

Opt into structured JSON output from the LLM by setting review.response_format to json_object. This instructs the LLM to return valid JSON, enabling machine-readable parsing and pipeline integration.

+
+
+ + + + .cora.yaml +
+
+
review:
+  response_format: json_object
+
+
+

Requires provider support for structured output. Works with OpenAI, Anthropic, and compatible APIs.

+
+ + +
+

+ + Anti-Hallucination +

+

cora uses two mechanisms to prevent the LLM from fabricating findings:

+
+
    +
  • File path injection โ€” Actual file paths are embedded in the prompt, anchoring the LLM to real files in the diff.
  • +
  • Post-parse filtering โ€” After parsing, any reported file paths or line numbers that don't exist in the actual diff are discarded.
  • +
+
+
+
diff --git a/website/src/routes/docs/examples/+page.svelte b/website/src/routes/docs/examples/+page.svelte new file mode 100644 index 00000000..bc550dd5 --- /dev/null +++ b/website/src/routes/docs/examples/+page.svelte @@ -0,0 +1,210 @@ + + + + Examples โ€” cora docs + + + +
+

Examples

+

Practical examples to get you started with cora.

+ + +
+

+ 01 + Quick Review +

+

Review your staged changes before committing.

+
+
+ + + +
+
+# Review staged changes (default)
+ $ cora review

+ # Or review with explicit flags
+ $ cora review --staged +
+
+
+ + +
+

+ 02 + Branch Comparison +

+

Compare your current branch against main.

+
+
+ + + +
+
+ $ cora review --base main +
+
+
+ + +
+

+ 03 + Full Project Scan +

+

Scan your entire project for issues.

+
+
+ + + +
+
+ $ cora scan +
+
+
+ + +
+

+ 04 + Incremental Scan +

+

Only scan files that changed since the last scan.

+
+
+ + + +
+
+ $ cora scan --incremental +
+
+
+ + +
+

+ 05 + Streaming Output +

+

Stream results as they come in from the LLM.

+
+
+ + + +
+
+ $ cora review --staged --stream +
+
+
+ + +
+

+ 06 + GitHub Actions CI +

+

Add cora to your CI pipeline.

+
+
+ + + + .github/workflows/cora-review.yml +
+
+
name: Code Review
+
+on:
+  pull_request:
+    branches: [main]
+
+jobs:
+  review:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+      - name: Install cora
+        run: cargo install cora-cli
+      - name: Run AI code review
+        env:
+          {@html 'CORA_API_KEY: ${{ secrets.CORA_API_KEY }}'}
+          CORA_PROVIDER: openai
+        run: cora review --base main --format sarif
+
+
+
+ + +
+

+ 07 + Pre-commit Hook +

+

Install once, then every commit gets reviewed automatically.

+
+
+ + + +
+
+ # Install the hook
+ $ cora hook install

+ # Now just commit normally — cora reviews automatically
+ $ git commit -m "fix: handle edge case in parser"
+ cora pre-commit hook running...
+ No issues found — commit allowed +
+
+
+ + +
+

+ 08 + SARIF Upload +

+

Generate SARIF output and upload to GitHub Code Scanning.

+
+
+ + + +
+
+ # Generate SARIF report and upload
+ $ cora review --base main --format sarif > results.sarif && \\
+   cora upload-sarif results.sarif

+ Uploaded 3 findings to GitHub Code Scanning +
+
+
+
diff --git a/website/src/routes/docs/getting-started/+page.svelte b/website/src/routes/docs/getting-started/+page.svelte new file mode 100644 index 00000000..775aaf7c --- /dev/null +++ b/website/src/routes/docs/getting-started/+page.svelte @@ -0,0 +1,157 @@ + + Getting Started โ€” cora docs + + + + +
+

+ Getting Started +

+ +
+

Quick Start

+

Get up and running with cora in four simple steps.

+ +
+
1
+
+ Install cora โ€” Single binary via Cargo: + cargo install cora-cli +
+
+ +
+
2
+
+ Configure โ€” Initialize your project: + cora init creates .cora.yaml +
+
+ +
+
3
+
+ Add API key โ€” Run cora auth login or set CORA_API_KEY environment variable +
+
+ +
+
4
+
+ Review โ€” Analyze your staged changes: + cora review +
+
+
+ +
+

Understanding the Output

+

cora outputs a structured, color-coded summary of findings for each file reviewed.

+ +
+
+ + + +
+
+
$ cora review --staged
+
+
Analyzing 3 files...
+
✓ src/auth/login.ts โ€” 2 issues found
+
⚠ Line 42: Potential SQL injection
+
⚠ Line 87: Hardcoded secret
+
✓ src/utils/parser.ts โ€” clean
+
✓ src/api/routes.ts โ€” 1 issue found
+
✗ Line 23: Missing error handling
+
+
3 issues found in 3 files
+
+
+ +

Each line in the output contains:

+
+
+ file path + The relative path to the file being reviewed +
+
+ line number + Specific line where the issue was found +
+
+ severity + Suggestion (⚠), Warning (⚠), or Error (✗) +
+
+ description + Brief explanation of the issue +
+
+
+ +
+

Customizing Your Review

+

The .cora.yaml file controls how cora reviews your code.

+ +
+
+ + + +
+
+
# .cora.yaml
+
review:
+
severity: warning
+
focus: security,performance
+
+
providers:
+
openai:
+
model: gpt-4o
+
+
+ +
+
+ review.severity โ€” Minimum severity level (info, minor, major, critical) +
+
+ review.focus โ€” Focus areas for review (e.g., security, performance) +
+
+ providers โ€” Provider-specific model overrides (openai, anthropic, groq, ollama, zai) +
+
+
+ + +
diff --git a/website/src/routes/docs/installation/+page.svelte b/website/src/routes/docs/installation/+page.svelte new file mode 100644 index 00000000..45363f01 --- /dev/null +++ b/website/src/routes/docs/installation/+page.svelte @@ -0,0 +1,167 @@ + + Installation โ€” cora docs + + + + +
+

+ Installation +

+ +
+

Prerequisites

+

cora is a Rust binary. You have two installation paths:

+
+
+ Via Cargo (recommended) โ€” Requires Rust 1.85 or later with Cargo installed +
+
+ Binary download โ€” No Rust required; just download and place in your PATH +
+
+

cora works on Linux (x86_64 and arm64), macOS (arm64), and Windows.

+
+ +
+

Install via Cargo

+

The recommended way to install cora is through Cargo:

+ +
+
+ + + +
+
+
$ cargo install cora-cli
+
+
+ +

This compiles cora from source and installs it to Cargo's binary directory (typically ~/.cargo/bin/).

+
+ +
+

Download Binary

+

Pre-built binaries are available from the GitHub Releases page.

+ +
+

Supported platforms:

+
+
Linux x86_64 (glibc)
+
Linux arm64 (aarch64)
+
macOS arm64 (Apple Silicon)
+
Windows x86_64
+
+
+ +
+
+ + + +
+
+
# Download and extract
+
$ curl -sL https://github.com/codecoradev/cora-cli/releases/latest/download/cora-linux-x86_64.tar.gz | tar xz
+
$ mv cora ~/.local/bin/cora
+
+
+
+ +
+

Build from Source

+

If you prefer to build from the latest source:

+ +
+
+ + + +
+
+
$ git clone https://github.com/codecoradev/cora-cli.git
+
$ cd cora-cli
+
$ cargo build --release
+
# Binary at target/release/cora
+
+
+
+ +
+

Shell Completions

+

cora provides shell completions for bash, zsh, and fish:

+ +
+
+ + + +
+
+
# Bash
+
$ cora completion bash > ~/.cora/completion.bash
+
$ echo 'source ~/.cora/completion.bash' >> ~/.bashrc
+
+
# Zsh
+
$ cora completion zsh > ~/.cora/completion.zsh
+
$ echo 'source ~/.cora/completion.zsh' >> ~/.zshrc
+
+
# Fish
+
$ cora completion fish > ~/.config/fish/completions/cora.fish
+
+
+
+ +
+

Verify Installation

+

Confirm cora is installed correctly:

+ +
+
+ + + +
+
+
$ cora --version
+
cora 0.x.x
+
+
$ cora auth status
+
Provider: openai
+
API key: configured
+
+
+
+ +
+

Updating

+

To update cora to the latest version:

+ +
+
+ Via Cargo: cargo install cora-cli --force +
+
+ Via Binary: Download the latest release from GitHub and replace the existing binary +
+
+
+
diff --git a/website/src/routes/docs/providers/+page.svelte b/website/src/routes/docs/providers/+page.svelte new file mode 100644 index 00000000..7ae143ba --- /dev/null +++ b/website/src/routes/docs/providers/+page.svelte @@ -0,0 +1,166 @@ + + + + Providers โ€” cora docs + + + +
+

Providers

+

cora supports multiple AI providers. Use your own API key — no subscriptions to us.

+ + +
+

+ + Supported Providers +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ProviderDefault ModelEnv VarCustom Base URL
OpenAIgpt-4o-miniOPENAI_API_KEYOPENAI_BASE_URL
Anthropicclaude-sonnet-4-20250514ANTHROPIC_API_KEYANTHROPIC_BASE_URL
Groqllama-3.1-8b-instantGROQ_API_KEYGROQ_BASE_URL
Ollamallama3.1— (local)OLLAMA_HOST (default: http://localhost:11434)
Z.AIglm-5.1ZAI_API_KEYZAI_BASE_URL
+
+
+ + +
+

+ + Auto-Detection +

+

+ cora automatically detects which provider to use by checking environment variables in this order: +

+
+ {#each [ + 'OPENAI_API_KEY \u2192 uses OpenAI', + 'ANTHROPIC_API_KEY \u2192 uses Anthropic', + 'GROQ_API_KEY \u2192 uses Groq', + 'ZAI_API_KEY \u2192 uses Z.AI', + 'OLLAMA_HOST \u2192 uses Ollama (localhost)' + ] as item, i} +
+
{i + 1}
+ {item} +
+ {/each} +
+

+ Override auto-detection with CORA_PROVIDER env var or --provider flag. +

+
+ + +
+

+ + Usage Examples +

+
+
+
+ + + +
+
+ # Use OpenAI (auto-detected from OPENAI_API_KEY)
+ $ OPENAI_API_KEY=sk-... cora review --staged +
+
+ +
+
+ + + +
+
+ # Use Anthropic with explicit provider
+ $ CORA_PROVIDER=anthropic CORA_API_KEY=sk-ant-... cora review --staged +
+
+ +
+
+ + + +
+
+ # Use Ollama locally (no API key needed)
+ $ CORA_PROVIDER=ollama cora review --staged +
+
+ +
+
+ + + +
+
+ # Use a custom model
+ $ CORA_MODEL=gpt-4o-mini cora review --staged +
+
+
+
+
diff --git a/website/src/routes/docs/usage/+page.svelte b/website/src/routes/docs/usage/+page.svelte new file mode 100644 index 00000000..4f622314 --- /dev/null +++ b/website/src/routes/docs/usage/+page.svelte @@ -0,0 +1,321 @@ + + Usage โ€” cora docs + + + + +
+

+ Usage +

+ +
+

Review Modes

+

cora supports multiple review modes, each suited to a different workflow:

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ModeFlagScopeBest For
Default(no flag)Tries staged first, then unpushedQuick feedback
Staged--stagedFiles in git staging areaPre-commit review
Unstaged--unstagedUnstaged working changesReview work in progress
Unpushed--unpushedCommits not yet pushedReview before push
Base Branch--base <branch>Diff against base branchPR review workflow
Commit--commit <ref>Specific commit or rangeReview specific changes
Diff File--diff-file <path>External diff fileReview patch files
+
+ +
+
+ + + +
+
+
# Review staged changes (default)
+
$ cora review
+
+
# Review against a branch
+
$ cora review --base main
+
+
# Review a specific commit
+
$ cora review --commit HEAD
+
+
# Review from a diff file
+
$ cora review --diff-file pr.diff
+
+
# Full project scan (use cora scan)
+
$ cora scan .
+
+
+
+ +
+

Output Formats

+

cora can output results in three formats:

+ +
+
+ --format pretty โ€” Human-readable terminal output (default) +
+
+ --format json โ€” Machine-readable JSON for CI/CD pipelines +
+
+ --format sarif โ€” SARIF format for GitHub Code Scanning +
+
+ +
+
+ + + +
+
+
# JSON output example
+
$ cora review --staged --format json
+
+
{'{'}
+
"files": [
+
{'{'}
+
"path": "src/auth/login.ts",
+
"issues": [
+
{'{'}
+
"line": 42,
+
"severity": "warning",
+
"message": "Potential SQL injection"
+
{'}'}
+
]
+
{'}'}
+
],
+
"summary": {'{'}
+
"total_files": 3,
+
"total_issues": 3
+
{'}'}
+
{'}'}
+
+
+
+ +
+

Configuration File

+

The .cora.yaml file provides persistent configuration. Place it in your project root. API keys are stored at ~/.cora/config.toml.

+ +
+
+ + + +
+
+
# .cora.yaml โ€” example
+
+
review:
+
severity: warning
+
focus: security,performance
+
+
ignore:
+
- "vendor/**"
+
- "*.min.js"
+
+
providers:
+
openai:
+
model: gpt-4o
+
+
+
+ +
+

Environment Variables

+

Environment variables override configuration file settings:

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
VariableDescriptionRequired
CORA_API_KEYAPI key for the configured providerYes (unless using cora auth)
CORA_PROVIDEROverride the LLM providerNo
CORA_MODELOverride the model nameNo
CORA_BASE_URLOverride the API base URLNo
CORA_CONFIGPath to alternative config fileNo
+
+
+ +
+

Working with Monorepos

+

cora works well in monorepo setups. Use include patterns to limit review scope to specific packages:

+ +
+
+ + + +
+
+
# Review only the backend package
+
$ cora review --staged --include "packages/backend/**"
+
+
# Exclude test and generated files
+
$ cora review --staged --exclude "**/*.test.ts" --exclude "**/generated/**"
+
+
+ +

Alternatively, set include/exclude patterns in .cora.yaml for persistent configuration.

+
+ +
+

Exit Codes

+

cora uses standard exit codes for scripting and CI integration:

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CodeMeaningCI Behavior
0No issues foundPass
1Issues foundFail (warning/error)
2Review blockedFail (auth/config error)
3Authentication errorFail (missing API key)
+
+
+ +
+

Tips

+
+
+ • + Use cora review with no flags for the fastest pre-commit feedback +
+
+ • + Combine --format json with --base main in CI pipelines +
+
+ • + Use cora scan . --incremental for large codebases โ€” only changed files are analyzed +
+
+ • + Use --quiet for minimal output and --severity to filter by severity level +
+
+ • + Use cora auth login to store API keys securely instead of environment variables +
+
+
+
diff --git a/website/static/favicon.png b/website/static/favicon.png new file mode 100644 index 00000000..db6d4be1 Binary files /dev/null and b/website/static/favicon.png differ diff --git a/website/static/og.png b/website/static/og.png new file mode 100644 index 00000000..3253c16f Binary files /dev/null and b/website/static/og.png differ diff --git a/website/static/robots.txt b/website/static/robots.txt new file mode 100644 index 00000000..c2a49f4f --- /dev/null +++ b/website/static/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Allow: / diff --git a/website/svelte.config.js b/website/svelte.config.js new file mode 100644 index 00000000..dc4f85e2 --- /dev/null +++ b/website/svelte.config.js @@ -0,0 +1,21 @@ +import adapter from '@sveltejs/adapter-static'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + preprocess: vitePreprocess(), + kit: { + adapter: adapter({ + pages: 'build', + assets: 'build', + fallback: '404.html', + precompress: false, + strict: true + }), + prerender: { + handleHttpError: 'warn' + } + } +}; + +export default config; diff --git a/website/tsconfig.json b/website/tsconfig.json new file mode 100644 index 00000000..a8f10c8e --- /dev/null +++ b/website/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } +} diff --git a/website/vite.config.ts b/website/vite.config.ts new file mode 100644 index 00000000..bf699a8d --- /dev/null +++ b/website/vite.config.ts @@ -0,0 +1,7 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import tailwindcss from '@tailwindcss/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [tailwindcss(), sveltekit()] +});