diff --git a/.github/workflows/CICD.md b/.github/workflows/CICD.md index 071d234a..53776a00 100644 --- a/.github/workflows/CICD.md +++ b/.github/workflows/CICD.md @@ -39,18 +39,19 @@ Trigger: pull_request to develop or master ## Merge to develop — pre-release (cd.yml) -Trigger: push to develop | Concurrency: cancel-in-progress +Trigger: push to develop | workflow_dispatch (not master) | Concurrency: cancel-in-progress ``` ┌──────────────────┐ │ push to develop │ + │ OR dispatch │ └────────┬─────────┘ │ ┌────────▼──────────────────┐ │ pre-release │ - │ read Cargo.toml version │ - │ tag = v{ver}-rc.{run} │ - │ safety: fail if exists │ + │ compute next version │ + │ from conventional commits │ + │ tag = v{next}-rc.{run} │ └────────┬──────────────────┘ │ ┌────────▼──────────────────┐ @@ -74,7 +75,7 @@ Trigger: push to develop | Concurrency: cancel-in-progress ## Merge to master — stable release (cd.yml) -Trigger: push to master | Concurrency: never cancelled +Trigger: push to master (only) | Concurrency: never cancelled ``` ┌──────────────────┐ diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 49d52bff..1d29a855 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -1,6 +1,7 @@ name: CD on: + workflow_dispatch: push: branches: [develop, master] @@ -18,7 +19,9 @@ jobs: # ═══════════════════════════════════════════════ pre-release: - if: github.ref == 'refs/heads/develop' + if: >- + github.ref == 'refs/heads/develop' + || (github.event_name == 'workflow_dispatch' && github.ref != 'refs/heads/master') runs-on: ubuntu-latest outputs: tag: ${{ steps.tag.outputs.tag }} @@ -26,17 +29,49 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 + fetch-tags: true - - name: Compute pre-release tag + - name: Compute version from commits like release please id: tag run: | - VERSION=$(grep '^version = ' Cargo.toml | head -1 | cut -d'"' -f2) - TAG="v${VERSION}-rc.${{ github.run_number }}" + LATEST_TAG=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' --sort=-version:refname | grep -v '-' | head -1) + if [ -z "$LATEST_TAG" ]; then + echo "::error::No stable release tag found" + exit 1 + fi + LATEST_VERSION="${LATEST_TAG#v}" + echo "Latest release: $LATEST_TAG" + + # ── Analyse conventional commits since that tag ── + COMMITS=$(git log "${LATEST_TAG}..HEAD" --format="%s") + HAS_BREAKING=$(echo "$COMMITS" | grep -cE '^[a-z]+(\(.+\))?!:' || true) + HAS_FEAT=$(echo "$COMMITS" | grep -cE '^feat(\(.+\))?:' || true) + HAS_FIX=$(echo "$COMMITS" | grep -cE '^fix(\(.+\))?:' || true) + echo "Commits since ${LATEST_TAG} — breaking=$HAS_BREAKING feat=$HAS_FEAT fix=$HAS_FIX" - # Safety: warn if this base version is already released - if git ls-remote --tags origin "refs/tags/v${VERSION}" | grep -q .; then - echo "::warning::v${VERSION} already released. Consider bumping Cargo.toml on develop." + # ── Compute next version (matches release-please observed behaviour) ── + # Pre-1.0 with bump-minor-pre-major: breaking → minor, feat → minor, fix → patch + IFS='.' read -r MAJOR MINOR PATCH <<< "$LATEST_VERSION" + if [ "$MAJOR" -eq 0 ]; then + if [ "$HAS_BREAKING" -gt 0 ] || [ "$HAS_FEAT" -gt 0 ]; then + MINOR=$((MINOR + 1)); PATCH=0 # breaking or feat → minor + else + PATCH=$((PATCH + 1)) # fix only → patch + fi + else + if [ "$HAS_BREAKING" -gt 0 ]; then + MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 # breaking → major + elif [ "$HAS_FEAT" -gt 0 ]; then + MINOR=$((MINOR + 1)); PATCH=0 # feat → minor + else + PATCH=$((PATCH + 1)) # fix → patch + fi fi + VERSION="${MAJOR}.${MINOR}.${PATCH}" + TAG="v${VERSION}-rc.${{ github.run_number }}" + + echo "Next version: $VERSION (from $LATEST_VERSION)" + echo "Pre-release tag: $TAG" # Safety: fail if this exact tag already exists if git ls-remote --tags origin "refs/tags/${TAG}" | grep -q .; then @@ -45,7 +80,6 @@ jobs: fi echo "tag=$TAG" >> $GITHUB_OUTPUT - echo "Pre-release tag: $TAG" build-prerelease: name: Build pre-release @@ -64,7 +98,7 @@ jobs: # ═══════════════════════════════════════════════ release-please: - if: github.ref == 'refs/heads/master' + if: github.ref == 'refs/heads/master' && github.event_name == 'push' runs-on: ubuntu-latest outputs: release_created: ${{ steps.release.outputs.release_created }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eae6866c..bad4b5d6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -201,14 +201,6 @@ jobs: - name: Run benchmark run: ./scripts/benchmark.sh - # ─── DCO: develop PRs only ─── - - check: - name: check - if: github.base_ref == 'develop' - runs-on: ubuntu-latest - steps: - - uses: KineticCafe/actions-dco@v1 # ─── AI Doc Review: develop PRs only ─── diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 5e4d2578..0ae617e1 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -272,6 +272,10 @@ PYTHON ruff_cmd.rs ruff check/format 80%+ ✓ GO go_cmd.rs go test/build/vet 75-90% ✓ golangci_cmd.rs golangci-lint 85% ✓ +RUBY rake_cmd.rs rake/rails test 85-90% ✓ + rspec_cmd.rs rspec 60%+ ✓ + rubocop_cmd.rs rubocop 60%+ ✓ + NETWORK wget_cmd.rs wget 85-95% ✓ curl_cmd.rs curl 70% ✓ @@ -303,6 +307,7 @@ SHARED utils.rs Helpers N/A ✓ - **JS/TS Tooling**: 8 modules (modern frontend/fullstack development) - **Python Tooling**: 3 modules (ruff, pytest, pip) - **Go Tooling**: 2 modules (go test/build/vet, golangci-lint) +- **Ruby Tooling**: 3 modules (rake/minitest, rspec, rubocop) + 1 TOML filter (bundle install) --- @@ -605,6 +610,37 @@ pub fn run(command: &GoCommand, verbose: u8) -> Result<()> { - Different output format (JSON API vs text) - Distinct use case (comprehensive linting vs single-tool diagnostics) +### Ruby Module Architecture + +**Added**: 2026-03-15 +**Motivation**: Ruby on Rails development support (minitest, RSpec, RuboCop, Bundler) + +Ruby modules follow the standalone command pattern (like Python) with a shared `ruby_exec()` utility for auto-detecting `bundle exec`. + +``` +Module Strategy Output Format Savings +───────────────────────────────────────────────────────────────────────── +rake_cmd.rs STATE MACHINE Text parser 85-90% + Minitest output (rake test / rails test) + → State machine: Header → Running → Failures → Summary + → All pass: "ok rake test: 8 runs, 0 failures" + → Failures: summary + numbered failure details + +rspec_cmd.rs JSON/TEXT DUAL JSON → 60%+ 60%+ + Injects --format json, parses structured results + → Fallback to text state machine when JSON unavailable + → Strips Spring, SimpleCov, DEPRECATION, Capybara noise + +rubocop_cmd.rs JSON PARSING JSON API 60%+ + Injects --format json, groups by cop/severity + → Skips JSON injection in autocorrect mode (-a, -A) + +bundle-install.toml TOML FILTER Text rules 90%+ + → Strips "Using" lines, short-circuits to "ok bundle: complete" +``` + +**Shared**: `ruby_exec(tool)` in utils.rs auto-detects `bundle exec` when `Gemfile` exists. Used by rake_cmd, rspec_cmd, rubocop_cmd. + ### Format Strategy Decision Tree ``` diff --git a/CHANGELOG.md b/CHANGELOG.md index 31a475c2..c9e441b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,24 @@ All notable changes to rtk (Rust Token Killer) will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Bug Fixes + +* **ruby:** use `rails test` instead of `rake test` when positional file args are passed — `rake test` ignores positional files and only supports `TEST=path` + +### Features + +* **ruby:** add RSpec test runner filter with JSON parsing and text fallback (60%+ reduction) +* **ruby:** add RuboCop linter filter with JSON parsing, grouped by cop/severity (60%+ reduction) +* **ruby:** add Minitest filter for `rake test` / `rails test` with state machine parser (85-90% reduction) +* **ruby:** add TOML filter for `bundle install/update` — strip `Using` lines (90%+ reduction) +* **ruby:** add `ruby_exec()` shared utility for auto-detecting `bundle exec` when Gemfile exists +* **ruby:** add discover/rewrite rules for rake, rails, rspec, rubocop, and bundle commands + +### Bug Fixes + +* **cargo:** preserve compile diagnostics when `cargo test` fails before any test suites run ## [0.31.0](https://github.com/rtk-ai/rtk/compare/v0.30.1...v0.31.0) (2026-03-19) diff --git a/CLAUDE.md b/CLAUDE.md index ab512961..35ff19ed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -230,8 +230,11 @@ rtk gain --history | grep proxy | pip_cmd.rs | pip/uv package manager | JSON parsing, auto-detect uv (70-85% reduction) | | go_cmd.rs | Go commands | NDJSON for test, text for build/vet (80-90% reduction) | | golangci_cmd.rs | golangci-lint | JSON parsing, group by rule (85% reduction) | +| rake_cmd.rs | Minitest via rake/rails test | State machine text parser, failures only (85-90% reduction) | +| rspec_cmd.rs | RSpec test runner | JSON injection + text fallback, failures only (60%+ reduction) | +| rubocop_cmd.rs | RuboCop linter | JSON injection, group by cop/severity (60%+ reduction) | | tee.rs | Full output recovery | Save raw output to file on failure, print hint for LLM re-read | -| utils.rs | Shared utilities | Package manager detection, common formatting | +| utils.rs | Shared utilities | Package manager detection, ruby_exec, common formatting | | discover/ | Claude Code history analysis | Scan JSONL sessions, classify commands, report missed savings | ## Performance Constraints @@ -392,6 +395,15 @@ pub fn execute_with_filter(cmd: &str, args: &[&str]) -> Result<()> { - **Architecture**: Standalone Python commands (mirror lint/prettier), Go sub-enum (mirror git/cargo) - **Patterns**: JSON for structured output (ruff check, golangci-lint, pip), NDJSON streaming (go test), text state machine (pytest), text filters (go build/vet, ruff format) +### Ruby on Rails Support (2026-03-15) +- **Ruby Commands**: 3 modules for Ruby/Rails development + - `rtk rspec`: RSpec test runner with JSON injection (`--format json`), text fallback (60%+ reduction) + - `rtk rubocop`: RuboCop linter with JSON injection, group by cop/severity (60%+ reduction) + - `rtk rake test`: Minitest filter via rake/rails test, state machine parser (85-90% reduction) +- **TOML Filter**: `bundle-install.toml` for bundle install/update — strips `Using` lines (90%+ reduction) +- **Shared Infrastructure**: `ruby_exec()` in utils.rs auto-detects `bundle exec` when Gemfile exists +- **Hook Integration**: Rewrites `rspec`, `rubocop`, `rake test`, `rails test`, `bundle exec` variants + ## Testing Strategy ### TDD Workflow (mandatory) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0ecb18c8..3221a21b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -89,15 +89,16 @@ Every change **must** include tests. See [Testing](#testing) below. Every change **must** include documentation updates. See [Documentation](#documentation) below. -### Developer Certificate of Origin (DCO) +### Contributor License Agreement (CLA) -All contributions must be signed off (git commit -s) to certify -you have the right to submit the code under the project's license. +All contributions require signing our [Contributor License Agreement (CLA)](CLA.md) before being merged. -Expected format: Signed-off-by: Your Name your@email.com -https://developercertificate.org/ +By signing, you certify that: +- You have authored 100% of the contribution, or have the necessary rights to submit it. +- You grant **rtk-ai** and **rtk-ai Labs** a perpetual, worldwide, royalty-free license to use your contribution — including in commercial products such as **rtk Pro** — under the [Apache License 2.0](LICENSE). +- If your employer has rights over your work, you have obtained their permission. -By signing off, you agree to the DCO. +**This is automatic.** When you open a Pull Request, [CLA Assistant](https://cla-assistant.io) will post a comment asking you to sign. Click the link in that comment to sign with your GitHub account. You only need to sign once. ### 5. Merge into `develop` diff --git a/LICENSE b/LICENSE index 5c5efcd4..0afaf4b9 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,190 @@ -MIT License - -Copyright (c) 2024 Patrick Szymkowiak - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2024 rtk-ai and rtk-ai Labs + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index d818e2af..6073d5eb 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ CI Release License: MIT - Discord + Discord Homebrew

@@ -19,7 +19,7 @@ InstallTroubleshootingArchitecture • - Discord + Discord

@@ -99,12 +99,15 @@ rtk gain # Should show token savings stats ## Quick Start ```bash -# 1. Install hook for Claude Code (recommended) -rtk init --global -# Follow instructions to register in ~/.claude/settings.json -# Claude Code only by default (use --opencode for OpenCode, --gemini for Gemini CLI) - -# 2. Restart Claude Code, then test +# 1. Install for your AI tool +rtk init -g # Claude Code / Copilot (default) +rtk init -g --gemini # Gemini CLI +rtk init -g --codex # Codex (OpenAI) +rtk init -g --agent cursor # Cursor +rtk init --agent windsurf # Windsurf +rtk init --agent cline # Cline / Roo Code + +# 2. Restart your AI tool, then test git status # Automatically rewritten to rtk git status ``` @@ -171,6 +174,8 @@ rtk playwright test # E2E results (failures only) rtk pytest # Python tests (-90%) rtk go test # Go tests (NDJSON, -90%) rtk cargo test # Cargo tests (-90%) +rtk rake test # Ruby minitest (-90%) +rtk rspec # RSpec tests (JSON, -60%+) ``` ### Build & Lint @@ -184,6 +189,7 @@ rtk cargo build # Cargo build (-80%) rtk cargo clippy # Cargo clippy (-80%) rtk ruff check # Python linting (JSON, -80%) rtk golangci-lint run # Go linting (JSON, -85%) +rtk rubocop # Ruby linting (JSON, -60%+) ``` ### Package Managers @@ -191,6 +197,7 @@ rtk golangci-lint run # Go linting (JSON, -85%) rtk pnpm list # Compact dependency tree rtk pip list # Python packages (auto-detect uv) rtk pip outdated # Outdated packages +rtk bundle install # Ruby gems (strip Using lines) rtk prisma generate # Schema generation (no ASCII art) ``` @@ -287,49 +294,96 @@ rtk init --show # Verify installation After install, **restart Claude Code**. -## Gemini CLI Support (Global) +## Supported AI Tools + +RTK supports 9 AI coding tools. Each integration transparently rewrites shell commands to `rtk` equivalents for 60-90% token savings. + +| Tool | Install | Method | +|------|---------|--------| +| **Claude Code** | `rtk init -g` | PreToolUse hook (bash) | +| **GitHub Copilot** | `rtk init -g` | PreToolUse hook (`rtk hook copilot`) | +| **Cursor** | `rtk init -g --agent cursor` | preToolUse hook (hooks.json) | +| **Gemini CLI** | `rtk init -g --gemini` | BeforeTool hook (`rtk hook gemini`) | +| **Codex** | `rtk init -g --codex` | AGENTS.md + RTK.md instructions | +| **Windsurf** | `rtk init --agent windsurf` | .windsurfrules (project-scoped) | +| **Cline / Roo Code** | `rtk init --agent cline` | .clinerules (project-scoped) | +| **OpenCode** | `rtk init -g --opencode` | Plugin TS (tool.execute.before) | +| **OpenClaw** | `openclaw plugins install ./openclaw` | Plugin TS (before_tool_call) | -RTK supports Gemini CLI via a native Rust hook processor. The hook intercepts `run_shell_command` tool calls and rewrites them to `rtk` equivalents using the same rewrite engine as Claude Code. +### Claude Code (default) -**Install Gemini hook:** ```bash -rtk init -g --gemini +rtk init -g # Install hook + RTK.md +rtk init -g --auto-patch # Non-interactive (CI/CD) +rtk init --show # Verify installation +rtk init -g --uninstall # Remove +``` + +### GitHub Copilot (VS Code + CLI) + +```bash +rtk init -g # Same hook as Claude Code ``` -**What it creates:** -- `~/.gemini/hooks/rtk-hook-gemini.sh` (thin wrapper calling `rtk hook gemini`) -- `~/.gemini/GEMINI.md` (RTK awareness instructions) -- Patches `~/.gemini/settings.json` with BeforeTool hook +The hook auto-detects Copilot format (VS Code `runTerminalCommand` or CLI `toolName: bash`) and rewrites commands. Works with both Copilot Chat in VS Code and `copilot` CLI. + +### Cursor + +```bash +rtk init -g --agent cursor +``` + +Creates `~/.cursor/hooks/rtk-rewrite.sh` + patches `~/.cursor/hooks.json` with preToolUse matcher. Works with both Cursor editor and `cursor-agent` CLI. + +### Gemini CLI -**Uninstall:** ```bash +rtk init -g --gemini rtk init -g --gemini --uninstall ``` -**Restart Required**: Restart Gemini CLI, then test with `git status` in a session. +Creates `~/.gemini/hooks/rtk-hook-gemini.sh` + patches `~/.gemini/settings.json` with BeforeTool hook. + +### Codex (OpenAI) -## OpenCode Plugin (Global) +```bash +rtk init -g --codex +``` -OpenCode supports plugins that can intercept tool execution. RTK provides a global plugin that mirrors the Claude auto-rewrite behavior by rewriting Bash tool commands to `rtk ...` before they execute. This plugin is **not** installed by default. +Creates `~/.codex/RTK.md` + `~/.codex/AGENTS.md` with `@RTK.md` reference. Codex reads these as global instructions. -> **Note**: This plugin uses OpenCode's `tool.execute.before` hook. Known limitation: plugin hooks do not intercept subagent tool calls ([upstream issue](https://github.com/sst/opencode/issues/5894)). See [OpenCode plugin docs](https://open-code.ai/en/docs/plugins) for API details. +### Windsurf + +```bash +rtk init --agent windsurf +``` + +Creates `.windsurfrules` in the current project. Cascade reads rules and prefixes commands with `rtk`. + +### Cline / Roo Code + +```bash +rtk init --agent cline +``` + +Creates `.clinerules` in the current project. Cline reads rules and prefixes commands with `rtk`. + +### OpenCode -**Install OpenCode plugin:** ```bash rtk init -g --opencode ``` -**What it creates:** -- `~/.config/opencode/plugins/rtk.ts` +Creates `~/.config/opencode/plugins/rtk.ts`. Uses `tool.execute.before` hook. -**Restart Required**: Restart OpenCode, then test with `git status` in a session. +### OpenClaw -**Manual install (fallback):** ```bash -mkdir -p ~/.config/opencode/plugins -cp hooks/opencode-rtk.ts ~/.config/opencode/plugins/rtk.ts +openclaw plugins install ./openclaw ``` +Plugin in `openclaw/` directory. Uses `before_tool_call` hook, delegates to `rtk rewrite`. + ### Commands Rewritten | Raw Command | Rewritten To | @@ -351,6 +405,10 @@ cp hooks/opencode-rtk.ts ~/.config/opencode/plugins/rtk.ts | `pip list/install` | `rtk pip ...` | | `go test/build/vet` | `rtk go ...` | | `golangci-lint` | `rtk golangci-lint` | +| `rake test` / `rails test` | `rtk rake test` | +| `rspec` / `bundle exec rspec` | `rtk rspec` | +| `rubocop` / `bundle exec rubocop` | `rtk rubocop` | +| `bundle install/update` | `rtk bundle ...` | | `docker ps/images/logs` | `rtk docker ...` | | `kubectl get/logs` | `rtk kubectl ...` | | `curl` | `rtk curl` | @@ -402,11 +460,33 @@ brew uninstall rtk # If installed via Homebrew - **[SECURITY.md](SECURITY.md)** - Security policy and PR review process - **[AUDIT_GUIDE.md](docs/AUDIT_GUIDE.md)** - Token savings analytics guide +## Privacy & Telemetry + +RTK collects **anonymous, aggregate usage metrics** once per day to help prioritize development. This is standard practice for open-source CLI tools. + +**What is collected:** +- Device hash (SHA-256 of hostname+username, not reversible) +- RTK version, OS, architecture +- Command count (last 24h) and top command names (e.g. "git", "cargo" — no arguments, no file paths) +- Token savings percentage + +**What is NOT collected:** source code, file paths, command arguments, secrets, environment variables, or any personally identifiable information. + +**Opt-out** (any of these): +```bash +# Environment variable +export RTK_TELEMETRY_DISABLED=1 + +# Or in config file (~/.config/rtk/config.toml) +[telemetry] +enabled = false +``` + ## Contributing Contributions welcome! Please open an issue or PR on [GitHub](https://github.com/rtk-ai/rtk). -Join the community on [Discord](https://discord.gg/pvHdzAec). +Join the community on [Discord](https://discord.gg/RySmvNF5kF). ## License diff --git a/README_es.md b/README_es.md index c05da936..c099d664 100644 --- a/README_es.md +++ b/README_es.md @@ -10,7 +10,7 @@ CI Release License: MIT - Discord + Discord Homebrew

@@ -19,7 +19,7 @@ InstalarSolucion de problemasArquitectura • - Discord + Discord

@@ -152,7 +152,7 @@ rtk discover # Descubrir ahorros perdidos Las contribuciones son bienvenidas. Abre un issue o PR en [GitHub](https://github.com/rtk-ai/rtk). -Unete a la comunidad en [Discord](https://discord.gg/pvHdzAec). +Unete a la comunidad en [Discord](https://discord.gg/RySmvNF5kF). ## Licencia diff --git a/README_fr.md b/README_fr.md index b8c71734..4c5e749d 100644 --- a/README_fr.md +++ b/README_fr.md @@ -10,7 +10,7 @@ CI Release License: MIT - Discord + Discord Homebrew

@@ -19,7 +19,7 @@ InstallerDepannageArchitecture • - Discord + Discord

@@ -190,7 +190,7 @@ mode = "failures" Les contributions sont les bienvenues ! Ouvrez une issue ou une PR sur [GitHub](https://github.com/rtk-ai/rtk). -Rejoignez la communaute sur [Discord](https://discord.gg/pvHdzAec). +Rejoignez la communaute sur [Discord](https://discord.gg/RySmvNF5kF). ## Licence diff --git a/README_ja.md b/README_ja.md index a6e7dc22..6c690aff 100644 --- a/README_ja.md +++ b/README_ja.md @@ -10,7 +10,7 @@ CI Release License: MIT - Discord + Discord Homebrew

@@ -19,7 +19,7 @@ インストールトラブルシューティングアーキテクチャ • - Discord + Discord

@@ -152,7 +152,7 @@ rtk discover # 見逃した節約機会を発見 コントリビューション歓迎![GitHub](https://github.com/rtk-ai/rtk) で issue または PR を作成してください。 -[Discord](https://discord.gg/pvHdzAec) コミュニティに参加。 +[Discord](https://discord.gg/RySmvNF5kF) コミュニティに参加。 ## ライセンス diff --git a/README_ko.md b/README_ko.md index b9eca724..5d3b1a0b 100644 --- a/README_ko.md +++ b/README_ko.md @@ -10,7 +10,7 @@ CI Release License: MIT - Discord + Discord Homebrew

@@ -19,7 +19,7 @@ 설치문제 해결아키텍처 • - Discord + Discord

@@ -152,7 +152,7 @@ rtk discover # 놓친 절약 기회 발견 기여를 환영합니다! [GitHub](https://github.com/rtk-ai/rtk)에서 issue 또는 PR을 생성해 주세요. -[Discord](https://discord.gg/pvHdzAec) 커뮤니티에 참여하세요. +[Discord](https://discord.gg/RySmvNF5kF) 커뮤니티에 참여하세요. ## 라이선스 diff --git a/README_zh.md b/README_zh.md index bd7fce8d..00b9c001 100644 --- a/README_zh.md +++ b/README_zh.md @@ -10,7 +10,7 @@ CI Release License: MIT - Discord + Discord Homebrew

@@ -19,7 +19,7 @@ 安装故障排除架构 • - Discord + Discord

@@ -160,7 +160,7 @@ rtk discover # 发现遗漏的节省机会 欢迎贡献!请在 [GitHub](https://github.com/rtk-ai/rtk) 上提交 issue 或 PR。 -加入 [Discord](https://discord.gg/pvHdzAec) 社区。 +加入 [Discord](https://discord.gg/RySmvNF5kF) 社区。 ## 许可证 diff --git a/scripts/test-all.sh b/scripts/test-all.sh index 4cbbef02..f0e2c06b 100755 --- a/scripts/test-all.sh +++ b/scripts/test-all.sh @@ -437,20 +437,42 @@ else skip_test "rtk gt" "gt not installed" fi -# ── 30. Global flags ──────────────────────────────── +# ── 30. Ruby (conditional) ────────────────────────── + +section "Ruby (conditional)" + +if command -v rspec &>/dev/null; then + assert_help "rtk rspec" rtk rspec --help +else + skip_test "rtk rspec" "rspec not installed" +fi + +if command -v rubocop &>/dev/null; then + assert_help "rtk rubocop" rtk rubocop --help +else + skip_test "rtk rubocop" "rubocop not installed" +fi + +if command -v rake &>/dev/null; then + assert_help "rtk rake" rtk rake --help +else + skip_test "rtk rake" "rake not installed" +fi + +# ── 31. Global flags ──────────────────────────────── section "Global flags" assert_ok "rtk -u ls ." rtk -u ls . assert_ok "rtk --skip-env npm --help" rtk --skip-env npm --help -# ── 31. CcEconomics ───────────────────────────────── +# ── 32. CcEconomics ───────────────────────────────── section "CcEconomics" assert_ok "rtk cc-economics" rtk cc-economics -# ── 32. Learn ─────────────────────────────────────── +# ── 33. Learn ─────────────────────────────────────── section "Learn" diff --git a/scripts/test-ruby.sh b/scripts/test-ruby.sh new file mode 100755 index 00000000..3b3008b9 --- /dev/null +++ b/scripts/test-ruby.sh @@ -0,0 +1,463 @@ +#!/usr/bin/env bash +# +# RTK Smoke Tests — Ruby (RSpec, RuboCop, Minitest, Bundle) +# Creates a minimal Rails app, exercises all Ruby RTK filters, then cleans up. +# Usage: bash scripts/test-ruby.sh +# +# Prerequisites: rtk (installed), ruby, bundler, rails gem +# Duration: ~60-120s (rails new + bundle install dominate) +# +set -euo pipefail + +PASS=0 +FAIL=0 +SKIP=0 +FAILURES=() + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +CYAN='\033[0;36m' +BOLD='\033[1m' +NC='\033[0m' + +# ── Helpers ────────────────────────────────────────── + +assert_ok() { + local name="$1"; shift + local output + if output=$("$@" 2>&1); then + PASS=$((PASS + 1)) + printf " ${GREEN}PASS${NC} %s\n" "$name" + else + FAIL=$((FAIL + 1)) + FAILURES+=("$name") + printf " ${RED}FAIL${NC} %s\n" "$name" + printf " cmd: %s\n" "$*" + printf " out: %s\n" "$(echo "$output" | head -3)" + fi +} + +assert_contains() { + local name="$1"; local needle="$2"; shift 2 + local output + if output=$("$@" 2>&1) && echo "$output" | grep -q "$needle"; then + PASS=$((PASS + 1)) + printf " ${GREEN}PASS${NC} %s\n" "$name" + else + FAIL=$((FAIL + 1)) + FAILURES+=("$name") + printf " ${RED}FAIL${NC} %s\n" "$name" + printf " expected: '%s'\n" "$needle" + printf " got: %s\n" "$(echo "$output" | head -3)" + fi +} + +# Allow non-zero exit but check output +assert_output() { + local name="$1"; local needle="$2"; shift 2 + local output + output=$("$@" 2>&1) || true + if echo "$output" | grep -qi "$needle"; then + PASS=$((PASS + 1)) + printf " ${GREEN}PASS${NC} %s\n" "$name" + else + FAIL=$((FAIL + 1)) + FAILURES+=("$name") + printf " ${RED}FAIL${NC} %s\n" "$name" + printf " expected: '%s'\n" "$needle" + printf " got: %s\n" "$(echo "$output" | head -3)" + fi +} + +skip_test() { + local name="$1"; local reason="$2" + SKIP=$((SKIP + 1)) + printf " ${YELLOW}SKIP${NC} %s (%s)\n" "$name" "$reason" +} + +# Assert command exits with non-zero and output matches needle +assert_exit_nonzero() { + local name="$1"; local needle="$2"; shift 2 + local output + local rc=0 + output=$("$@" 2>&1) || rc=$? + if [[ $rc -ne 0 ]] && echo "$output" | grep -qi "$needle"; then + PASS=$((PASS + 1)) + printf " ${GREEN}PASS${NC} %s (exit=%d)\n" "$name" "$rc" + else + FAIL=$((FAIL + 1)) + FAILURES+=("$name") + printf " ${RED}FAIL${NC} %s (exit=%d)\n" "$name" "$rc" + if [[ $rc -eq 0 ]]; then + printf " expected non-zero exit, got 0\n" + else + printf " expected: '%s'\n" "$needle" + fi + printf " out: %s\n" "$(echo "$output" | head -3)" + fi +} + +section() { + printf "\n${BOLD}${CYAN}── %s ──${NC}\n" "$1" +} + +# ── Prerequisite checks ───────────────────────────── + +RTK=$(command -v rtk || echo "") +if [[ -z "$RTK" ]]; then + echo "rtk not found in PATH. Run: cargo install --path ." + exit 1 +fi + +if ! command -v ruby >/dev/null 2>&1; then + echo "ruby not found in PATH. Install Ruby first." + exit 1 +fi + +if ! command -v bundle >/dev/null 2>&1; then + echo "bundler not found in PATH. Run: gem install bundler" + exit 1 +fi + +if ! command -v rails >/dev/null 2>&1; then + echo "rails not found in PATH. Run: gem install rails" + exit 1 +fi + +# ── Preamble ───────────────────────────────────────── + +printf "${BOLD}RTK Smoke Tests — Ruby (RSpec, RuboCop, Minitest, Bundle)${NC}\n" +printf "Binary: %s (%s)\n" "$RTK" "$(rtk --version)" +printf "Ruby: %s\n" "$(ruby --version)" +printf "Rails: %s\n" "$(rails --version)" +printf "Bundler: %s\n" "$(bundle --version)" +printf "Date: %s\n\n" "$(date '+%Y-%m-%d %H:%M')" + +# ── Temp dir + cleanup trap ────────────────────────── + +TMPDIR=$(mktemp -d /tmp/rtk-ruby-smoke-XXXXXX) +trap 'rm -rf "$TMPDIR"' EXIT + +printf "${BOLD}Setting up temporary Rails app in %s ...${NC}\n" "$TMPDIR" + +# ── Setup phase (not counted in assertions) ────────── + +cd "$TMPDIR" + +# 1. Create minimal Rails app +printf " → rails new (--minimal --skip-git --skip-docker) ...\n" +rails new rtk_smoke_app --minimal --skip-git --skip-docker --quiet 2>&1 | tail -1 || true +cd rtk_smoke_app + +# 2. Add rspec-rails and rubocop to Gemfile +cat >> Gemfile <<'GEMFILE' + +group :development, :test do + gem 'rspec-rails' + gem 'rubocop', require: false +end +GEMFILE + +# 3. Bundle install +printf " → bundle install ...\n" +bundle install --quiet 2>&1 | tail -1 || true + +# 4. Generate scaffold (creates model + minitest files) +printf " → rails generate scaffold Post ...\n" +rails generate scaffold Post title:string body:text published:boolean --quiet 2>&1 | tail -1 || true + +# 5. Install RSpec + create manual spec file +printf " → rails generate rspec:install ...\n" +rails generate rspec:install --quiet 2>&1 | tail -1 || true + +mkdir -p spec/models +cat > spec/models/post_spec.rb <<'SPEC' +require 'rails_helper' + +RSpec.describe Post, type: :model do + it "is valid with valid attributes" do + post = Post.new(title: "Test", body: "Body", published: false) + expect(post).to be_valid + end +end +SPEC + +# 6. Create + migrate database +printf " → rails db:create && db:migrate ...\n" +rails db:create --quiet 2>&1 | tail -1 || true +rails db:migrate --quiet 2>&1 | tail -1 || true + +# 7. Create a file with intentional RuboCop offenses +printf " → creating rubocop_bait.rb with intentional offenses ...\n" +cat > app/models/rubocop_bait.rb <<'BAIT' +class RubocopBait < ApplicationRecord + def messy_method() + x = 1 + y = 2 + if x == 1 + puts "hello world" + end + return nil + end +end +BAIT + +# 8. Create a failing RSpec spec +printf " → creating failing rspec spec ...\n" +cat > spec/models/post_fail_spec.rb <<'FAILSPEC' +require 'rails_helper' + +RSpec.describe Post, type: :model do + it "intentionally fails validation check" do + post = Post.new(title: "Hello", body: "World", published: false) + expect(post.title).to eq("Wrong Title On Purpose") + end +end +FAILSPEC + +# 9. Create an RSpec spec with pending example +printf " → creating rspec spec with pending example ...\n" +cat > spec/models/post_pending_spec.rb <<'PENDSPEC' +require 'rails_helper' + +RSpec.describe Post, type: :model do + it "is valid with title" do + post = Post.new(title: "OK", body: "Body", published: false) + expect(post).to be_valid + end + + it "will support markdown later" do + pending "Not yet implemented" + expect(Post.new.render_markdown).to eq("

hello

") + end +end +PENDSPEC + +# 10. Create a failing minitest test +printf " → creating failing minitest test ...\n" +cat > test/models/post_fail_test.rb <<'FAILTEST' +require "test_helper" + +class PostFailTest < ActiveSupport::TestCase + test "intentionally fails" do + assert_equal "wrong", Post.new(title: "right").title + end +end +FAILTEST + +# 11. Create a passing minitest test +printf " → creating passing minitest test ...\n" +cat > test/models/post_pass_test.rb <<'PASSTEST' +require "test_helper" + +class PostPassTest < ActiveSupport::TestCase + test "post is valid" do + post = Post.new(title: "OK", body: "Body", published: false) + assert post.valid? + end +end +PASSTEST + +printf "\n${BOLD}Setup complete. Running tests...${NC}\n" + +# ══════════════════════════════════════════════════════ +# Test sections +# ══════════════════════════════════════════════════════ + +# ── 1. RSpec ───────────────────────────────────────── + +section "RSpec" + +assert_output "rtk rspec (with failure)" \ + "failed" \ + rtk rspec + +assert_output "rtk rspec spec/models/post_spec.rb (pass)" \ + "RSpec.*passed" \ + rtk rspec spec/models/post_spec.rb + +assert_output "rtk rspec spec/models/post_fail_spec.rb (fail)" \ + "failed\|❌" \ + rtk rspec spec/models/post_fail_spec.rb + +# ── 2. RuboCop ─────────────────────────────────────── + +section "RuboCop" + +assert_output "rtk rubocop (with offenses)" \ + "offense" \ + rtk rubocop + +assert_output "rtk rubocop app/ (with offenses)" \ + "rubocop_bait\|offense" \ + rtk rubocop app/ + +# ── 3. Minitest (rake test) ────────────────────────── + +section "Minitest (rake test)" + +assert_output "rtk rake test (with failure)" \ + "failure\|error\|FAIL" \ + rtk rake test + +assert_output "rtk rake test single passing file" \ + "ok rake test\|0 failures" \ + rtk rake test TEST=test/models/post_pass_test.rb + +assert_exit_nonzero "rtk rake test single failing file" \ + "failure\|FAIL" \ + rtk rake test test/models/post_fail_test.rb + +# ── 4. Bundle install ──────────────────────────────── + +section "Bundle install" + +assert_output "rtk bundle install (idempotent)" \ + "bundle\|ok\|complete\|install" \ + rtk bundle install + +# ── 5. Exit code preservation ──────────────────────── + +section "Exit code preservation" + +assert_exit_nonzero "rtk rspec exits non-zero on failure" \ + "failed\|failure" \ + rtk rspec spec/models/post_fail_spec.rb + +assert_exit_nonzero "rtk rubocop exits non-zero on offenses" \ + "offense" \ + rtk rubocop app/models/rubocop_bait.rb + +assert_exit_nonzero "rtk rake test exits non-zero on failure" \ + "failure\|FAIL" \ + rtk rake test test/models/post_fail_test.rb + +# ── 6. bundle exec variants ───────────────────────── + +section "bundle exec variants" + +assert_output "bundle exec rspec spec/models/post_spec.rb" \ + "passed\|example" \ + rtk bundle exec rspec spec/models/post_spec.rb + +assert_output "bundle exec rubocop app/" \ + "offense" \ + rtk bundle exec rubocop app/ + +# ── 7. RuboCop autocorrect ─────────────────────────── + +section "RuboCop autocorrect" + +# Copy bait file so autocorrect has something to fix +cp app/models/rubocop_bait.rb app/models/rubocop_bait_ac.rb +sed -i.bak 's/RubocopBait/RubocopBaitAc/' app/models/rubocop_bait_ac.rb + +assert_output "rtk rubocop -A (autocorrect)" \ + "autocorrected\|rubocop\|ok\|offense\|inspected" \ + rtk rubocop -A app/models/rubocop_bait_ac.rb + +# Clean up autocorrect test file +rm -f app/models/rubocop_bait_ac.rb app/models/rubocop_bait_ac.rb.bak + +# ── 8. RSpec pending ───────────────────────────────── + +section "RSpec pending" + +assert_output "rtk rspec with pending example" \ + "pending" \ + rtk rspec spec/models/post_pending_spec.rb + +# ── 9. RSpec text fallback ─────────────────────────── + +section "RSpec text fallback" + +assert_output "rtk rspec --format documentation (text path)" \ + "valid\|example\|post" \ + rtk rspec --format documentation spec/models/post_spec.rb + +# ── 10. RSpec empty suite ──────────────────────────── + +section "RSpec empty suite" + +assert_output "rtk rspec nonexistent tag" \ + "0 examples\|No examples" \ + rtk rspec --tag nonexistent spec/models/post_spec.rb + +# ── 11. Token savings ──────────────────────────────── + +section "Token savings" + +# rspec (passing spec) +raw_len=$( (bundle exec rspec spec/models/post_spec.rb 2>&1 || true) | wc -c | tr -d ' ') +rtk_len=$( (rtk rspec spec/models/post_spec.rb 2>&1 || true) | wc -c | tr -d ' ') +if [[ "$rtk_len" -lt "$raw_len" ]]; then + PASS=$((PASS + 1)) + printf " ${GREEN}PASS${NC} rspec: rtk (%s bytes) < raw (%s bytes)\n" "$rtk_len" "$raw_len" +else + FAIL=$((FAIL + 1)) + FAILURES+=("token savings: rspec") + printf " ${RED}FAIL${NC} rspec: rtk (%s bytes) >= raw (%s bytes)\n" "$rtk_len" "$raw_len" +fi + +# rubocop (exits non-zero on offenses, so || true) +raw_len=$( (bundle exec rubocop app/ 2>&1 || true) | wc -c | tr -d ' ') +rtk_len=$( (rtk rubocop app/ 2>&1 || true) | wc -c | tr -d ' ') +if [[ "$rtk_len" -lt "$raw_len" ]]; then + PASS=$((PASS + 1)) + printf " ${GREEN}PASS${NC} rubocop: rtk (%s bytes) < raw (%s bytes)\n" "$rtk_len" "$raw_len" +else + FAIL=$((FAIL + 1)) + FAILURES+=("token savings: rubocop") + printf " ${RED}FAIL${NC} rubocop: rtk (%s bytes) >= raw (%s bytes)\n" "$rtk_len" "$raw_len" +fi + +# rake test (passing file) +raw_len=$( (bundle exec rake test TEST=test/models/post_pass_test.rb 2>&1 || true) | wc -c | tr -d ' ') +rtk_len=$( (rtk rake test test/models/post_pass_test.rb 2>&1 || true) | wc -c | tr -d ' ') +if [[ "$rtk_len" -lt "$raw_len" ]]; then + PASS=$((PASS + 1)) + printf " ${GREEN}PASS${NC} rake test: rtk (%s bytes) < raw (%s bytes)\n" "$rtk_len" "$raw_len" +else + FAIL=$((FAIL + 1)) + FAILURES+=("token savings: rake test") + printf " ${RED}FAIL${NC} rake test: rtk (%s bytes) >= raw (%s bytes)\n" "$rtk_len" "$raw_len" +fi + +# bundle install (idempotent) +raw_len=$( (bundle install 2>&1 || true) | wc -c | tr -d ' ') +rtk_len=$( (rtk bundle install 2>&1 || true) | wc -c | tr -d ' ') +if [[ "$rtk_len" -lt "$raw_len" ]]; then + PASS=$((PASS + 1)) + printf " ${GREEN}PASS${NC} bundle install: rtk (%s bytes) < raw (%s bytes)\n" "$rtk_len" "$raw_len" +else + FAIL=$((FAIL + 1)) + FAILURES+=("token savings: bundle install") + printf " ${RED}FAIL${NC} bundle install: rtk (%s bytes) >= raw (%s bytes)\n" "$rtk_len" "$raw_len" +fi + +# ── 12. Verbose flag ───────────────────────────────── + +section "Verbose flag (-v)" + +assert_output "rtk -v rspec (verbose)" \ + "RSpec\|passed\|Running\|example" \ + rtk -v rspec spec/models/post_spec.rb + +# ══════════════════════════════════════════════════════ +# Report +# ══════════════════════════════════════════════════════ + +printf "\n${BOLD}══════════════════════════════════════${NC}\n" +printf "${BOLD}Results: ${GREEN}%d passed${NC}, ${RED}%d failed${NC}, ${YELLOW}%d skipped${NC}\n" "$PASS" "$FAIL" "$SKIP" + +if [[ ${#FAILURES[@]} -gt 0 ]]; then + printf "\n${RED}Failures:${NC}\n" + for f in "${FAILURES[@]}"; do + printf " - %s\n" "$f" + done +fi + +printf "${BOLD}══════════════════════════════════════${NC}\n" + +exit "$FAIL" diff --git a/src/cargo_cmd.rs b/src/cargo_cmd.rs index 63eea4b7..eabf8a37 100644 --- a/src/cargo_cmd.rs +++ b/src/cargo_cmd.rs @@ -850,6 +850,18 @@ fn filter_cargo_test(output: &str) -> String { } if result.trim().is_empty() { + let has_compile_errors = output.lines().any(|line| { + let trimmed = line.trim_start(); + trimmed.starts_with("error[") || trimmed.starts_with("error:") + }); + + if has_compile_errors { + let build_filtered = filter_cargo_build(output); + if build_filtered.starts_with("cargo build:") { + return build_filtered.replacen("cargo build:", "cargo test:", 1); + } + } + // Fallback: show last meaningful lines let meaningful: Vec<&str> = output .lines() @@ -1314,6 +1326,29 @@ test result: MALFORMED LINE WITHOUT PROPER FORMAT ); } + #[test] + fn test_filter_cargo_test_compile_error_preserves_error_header() { + let output = r#" Compiling rtk v0.31.0 (/workspace/projects/rtk) +error[E0425]: cannot find value `missing_symbol` in this scope + --> tests/repro_compile_fail.rs:3:13 + | +3 | let _ = missing_symbol; + | ^^^^^^^^^^^^^^ not found in this scope + +For more information about this error, try `rustc --explain E0425`. +error: could not compile `rtk` (test "repro_compile_fail") due to 1 previous error +"#; + let result = filter_cargo_test(output); + assert!(result.contains("cargo test: 1 errors, 0 warnings (1 crates)")); + assert!(result.contains("error[E0425]"), "got: {}", result); + assert!( + result.contains("--> tests/repro_compile_fail.rs:3:13"), + "got: {}", + result + ); + assert!(!result.starts_with('|'), "got: {}", result); + } + #[test] fn test_filter_cargo_clippy_clean() { let output = r#" Checking rtk v0.5.0 diff --git a/src/discover/rules.rs b/src/discover/rules.rs index 00c79301..44f19d60 100644 --- a/src/discover/rules.rs +++ b/src/discover/rules.rs @@ -44,6 +44,11 @@ pub const PATTERNS: &[&str] = &[ // Go tooling r"^go\s+(test|build|vet)", r"^golangci-lint(\s|$)", + // Ruby tooling + r"^bundle\s+(install|update)\b", + r"^(?:bundle\s+exec\s+)?(?:bin/)?(?:rake|rails)\s+test", + r"^(?:bundle\s+exec\s+)?rspec(?:\s|$)", + r"^(?:bundle\s+exec\s+)?rubocop(?:\s|$)", // AWS CLI r"^aws\s+", // PostgreSQL @@ -332,6 +337,45 @@ pub const RULES: &[RtkRule] = &[ subcmd_savings: &[], subcmd_status: &[], }, + // Ruby tooling + RtkRule { + rtk_cmd: "rtk bundle", + rewrite_prefixes: &["bundle"], + category: "Ruby", + savings_pct: 70.0, + subcmd_savings: &[], + subcmd_status: &[], + }, + RtkRule { + rtk_cmd: "rtk rake", + rewrite_prefixes: &[ + "bundle exec rails", + "bundle exec rake", + "bin/rails", + "rails", + "rake", + ], + category: "Ruby", + savings_pct: 85.0, + subcmd_savings: &[("test", 90.0)], + subcmd_status: &[], + }, + RtkRule { + rtk_cmd: "rtk rspec", + rewrite_prefixes: &["bundle exec rspec", "bin/rspec", "rspec"], + category: "Tests", + savings_pct: 65.0, + subcmd_savings: &[], + subcmd_status: &[], + }, + RtkRule { + rtk_cmd: "rtk rubocop", + rewrite_prefixes: &["bundle exec rubocop", "rubocop"], + category: "Build", + savings_pct: 65.0, + subcmd_savings: &[], + subcmd_status: &[], + }, // AWS CLI RtkRule { rtk_cmd: "rtk aws", diff --git a/src/dotnet_cmd.rs b/src/dotnet_cmd.rs index 07bc0d3a..dde3bba5 100644 --- a/src/dotnet_cmd.rs +++ b/src/dotnet_cmd.rs @@ -4,6 +4,9 @@ use crate::dotnet_trx; use crate::tracking; use crate::utils::{resolved_command, truncate}; use anyhow::{Context, Result}; +use quick_xml::events::Event; +use quick_xml::Reader; +use serde_json::Value; use std::ffi::OsString; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; @@ -492,25 +495,56 @@ fn build_effective_dotnet_args( effective.push("-v:minimal".to_string()); } - if !has_nologo_arg(args) { + let runner_mode = if subcommand == "test" { + detect_test_runner_mode(args) + } else { + TestRunnerMode::Classic + }; + + // --nologo: skip for MtpNative — args pass directly to the MTP runtime which + // does not understand MSBuild/VSTest flags. + if runner_mode != TestRunnerMode::MtpNative && !has_nologo_arg(args) { effective.push("-nologo".to_string()); } if subcommand == "test" { - if !has_trx_logger_arg(args) { - effective.push("--logger".to_string()); - effective.push("trx".to_string()); - } - - if !has_results_directory_arg(args) { - if let Some(results_dir) = trx_results_dir { - effective.push("--results-directory".to_string()); - effective.push(results_dir.display().to_string()); + match runner_mode { + TestRunnerMode::Classic => { + if !has_trx_logger_arg(args) { + effective.push("--logger".to_string()); + effective.push("trx".to_string()); + } + if !has_results_directory_arg(args) { + if let Some(results_dir) = trx_results_dir { + effective.push("--results-directory".to_string()); + effective.push(results_dir.display().to_string()); + } + } + effective.extend(args.iter().cloned()); + } + TestRunnerMode::MtpNative => { + // In .NET 10 native MTP mode, --report-trx is a direct dotnet test flag. + // Modern MTP frameworks (TUnit 1.19.74+, MSTest, xUnit with MTP runner) + // include Microsoft.Testing.Extensions.TrxReport natively. + if !has_report_trx_arg(args) { + effective.push("--report-trx".to_string()); + } + effective.extend(args.iter().cloned()); + } + TestRunnerMode::MtpVsTestBridge => { + // In VsTestBridge mode (supported on .NET 9 SDK and earlier), --report-trx + // goes after the -- separator so it reaches the MTP runtime. + if !has_report_trx_arg(args) { + effective.extend(inject_report_trx_into_args(args)); + } else { + effective.extend(args.iter().cloned()); + } } } + } else { + effective.extend(args.iter().cloned()); } - effective.extend(args.iter().cloned()); effective } @@ -533,6 +567,176 @@ fn has_verbosity_arg(args: &[String]) -> bool { }) } +/// How the targeted test project(s) run tests — determines which TRX injection strategy to use. +#[derive(Debug, PartialEq)] +enum TestRunnerMode { + /// Classic VSTest runner. Inject `--logger trx --results-directory`. + Classic, + /// Native MTP runner (`UseMicrosoftTestingPlatformRunner`, `UseTestingPlatformRunner`, or + /// global.json MTP mode). `--logger trx` breaks the run; inject `--report-trx` directly. + MtpNative, + /// VSTest bridge for MTP (`TestingPlatformDotnetTestSupport=true`). `--logger trx` is + /// silently ignored; MTP args must come after `--`. Inject `-- --report-trx`. + MtpVsTestBridge, +} + +/// Which MTP-related property a single MSBuild file declares. +#[derive(Debug, PartialEq)] +enum MtpProjectKind { + None, + VsTestBridge, // UseMicrosoftTestingPlatformRunner | UseTestingPlatformRunner | TestingPlatformDotnetTestSupport +} + +/// Scans a single MSBuild file (.csproj / .fsproj / .vbproj / Directory.Build.props) for +/// MTP-related properties and returns which kind it is. +fn scan_mtp_kind_in_file(path: &Path) -> MtpProjectKind { + let content = match std::fs::read_to_string(path) { + Ok(c) => c, + Err(_) => return MtpProjectKind::None, + }; + + let mut reader = Reader::from_str(&content); + reader.config_mut().trim_text(true); + let mut buf = Vec::new(); + let mut inside_mtp_element = false; + + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(e)) => { + let name_lower = e.local_name().as_ref().to_ascii_lowercase(); + // All project-file MTP properties run in VSTest bridge mode and require + // MTP-specific args to come after `--`. Only global.json MTP mode is native. + inside_mtp_element = matches!( + name_lower.as_slice(), + b"usemicrosofttestingplatformrunner" + | b"usetestingplatformrunner" + | b"testingplatformdotnettestsupport" + ); + } + Ok(Event::Text(e)) => { + if inside_mtp_element { + if let Ok(text) = e.unescape() { + if text.trim().eq_ignore_ascii_case("true") { + return MtpProjectKind::VsTestBridge; + } + } + } + } + Ok(Event::End(_)) => inside_mtp_element = false, + Ok(Event::Eof) => break, + Err(_) => break, + _ => {} + } + buf.clear(); + } + + MtpProjectKind::None +} + +fn parse_global_json_mtp_mode(path: &Path) -> bool { + let Ok(content) = std::fs::read_to_string(path) else { + return false; + }; + let Ok(json) = serde_json::from_str::(&content) else { + return false; + }; + json.get("test") + .and_then(|t| t.get("runner")) + .and_then(|r| r.as_str()) + .is_some_and(|r| r.eq_ignore_ascii_case("Microsoft.Testing.Platform")) +} + +/// Checks whether the `global.json` closest to the current directory enables the .NET 10 +/// native MTP mode (`"test": { "runner": "Microsoft.Testing.Platform" }`). +fn is_global_json_mtp_mode() -> bool { + let Ok(mut dir) = std::env::current_dir() else { + return false; + }; + loop { + let path = dir.join("global.json"); + if path.exists() { + let is_mtp = parse_global_json_mtp_mode(&path); + return is_mtp; // stop at first global.json found, regardless of result + } + if !dir.pop() { + break; + } + } + false +} + +/// Detects which test runner mode the targeted project(s) use. +/// +/// Priority order: global.json (MtpNative) > project-file/Directory.Build.props (MtpVsTestBridge) > Classic. +/// `global.json` MTP mode is checked first because it overrides all project-level properties. +fn detect_test_runner_mode(args: &[String]) -> TestRunnerMode { + // global.json MTP mode takes overall precedence — when set, dotnet test runs MTP + // natively regardless of project file properties. + if is_global_json_mtp_mode() { + return TestRunnerMode::MtpNative; + } + + let project_extensions = ["csproj", "fsproj", "vbproj"]; + + let explicit_projects: Vec<&str> = args + .iter() + .map(String::as_str) + .filter(|a| { + let lower = a.to_ascii_lowercase(); + project_extensions + .iter() + .any(|ext| lower.ends_with(&format!(".{ext}"))) + }) + .collect(); + + let mut found = MtpProjectKind::None; + + if !explicit_projects.is_empty() { + for p in &explicit_projects { + if scan_mtp_kind_in_file(Path::new(p)) == MtpProjectKind::VsTestBridge { + found = MtpProjectKind::VsTestBridge; + } + } + } else { + // No explicit project — scan current directory. + if let Ok(entries) = std::fs::read_dir(".") { + for entry in entries.flatten() { + let name = entry.file_name(); + let name_str = name.to_string_lossy().to_ascii_lowercase(); + if project_extensions + .iter() + .any(|ext| name_str.ends_with(&format!(".{ext}"))) + && scan_mtp_kind_in_file(&entry.path()) == MtpProjectKind::VsTestBridge + { + found = MtpProjectKind::VsTestBridge; + } + } + } + } + + if found == MtpProjectKind::VsTestBridge { + return TestRunnerMode::MtpVsTestBridge; + } + + // Walk up from current directory looking for Directory.Build.props. + if let Ok(mut dir) = std::env::current_dir() { + loop { + let props = dir.join("Directory.Build.props"); + if props.exists() { + if scan_mtp_kind_in_file(&props) == MtpProjectKind::VsTestBridge { + return TestRunnerMode::MtpVsTestBridge; + } + break; // only read the first (closest) Directory.Build.props + } + if !dir.pop() { + break; + } + } + } + + TestRunnerMode::Classic +} + fn has_nologo_arg(args: &[String]) -> bool { args.iter() .any(|arg| matches!(arg.to_ascii_lowercase().as_str(), "-nologo" | "/nologo")) @@ -578,6 +782,25 @@ fn has_report_arg(args: &[String]) -> bool { }) } +fn has_report_trx_arg(args: &[String]) -> bool { + args.iter().any(|a| a.eq_ignore_ascii_case("--report-trx")) +} + +/// Injects `--report-trx` after the `--` separator in `args`. +/// If no `--` separator exists, appends `-- --report-trx` at the end. +fn inject_report_trx_into_args(args: &[String]) -> Vec { + if let Some(sep) = args.iter().position(|a| a == "--") { + let mut result = args.to_vec(); + result.insert(sep + 1, "--report-trx".to_string()); + result + } else { + let mut result = args.to_vec(); + result.push("--".to_string()); + result.push("--report-trx".to_string()); + result + } +} + fn extract_report_arg(args: &[String]) -> Option { let mut iter = args.iter().peekable(); while let Some(arg) = iter.next() { @@ -1474,6 +1697,336 @@ mod tests { .any(|w| w[0] == "--results-directory" && w[1] == "/custom/results")); } + #[test] + fn test_scan_mtp_kind_detects_use_microsoft_testing_platform_runner() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let csproj = temp_dir.path().join("MyProject.csproj"); + fs::write( + &csproj, + r#" + + true + +"#, + ) + .expect("write csproj"); + + assert_eq!(scan_mtp_kind_in_file(&csproj), MtpProjectKind::VsTestBridge); + } + + #[test] + fn test_scan_mtp_kind_detects_use_testing_platform_runner() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let csproj = temp_dir.path().join("MyProject.csproj"); + fs::write( + &csproj, + r#" + + true + +"#, + ) + .expect("write csproj"); + + assert_eq!(scan_mtp_kind_in_file(&csproj), MtpProjectKind::VsTestBridge); + } + + #[test] + fn test_is_mtp_project_file_returns_false_for_classic_vstest() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let csproj = temp_dir.path().join("MyProject.csproj"); + fs::write( + &csproj, + r#" + + net9.0 + + + + +"#, + ) + .expect("write csproj"); + + assert_eq!(scan_mtp_kind_in_file(&csproj), MtpProjectKind::None); + } + + #[test] + fn test_scan_mtp_kind_returns_none_when_value_is_false() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let csproj = temp_dir.path().join("MyProject.csproj"); + fs::write( + &csproj, + r#" + + false + +"#, + ) + .expect("write csproj"); + + assert_eq!(scan_mtp_kind_in_file(&csproj), MtpProjectKind::None); + } + + #[test] + fn test_scan_mtp_kind_detects_vstest_bridge() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let csproj = temp_dir.path().join("MSTest.Tests.csproj"); + fs::write( + &csproj, + r#" + + true + +"#, + ) + .expect("write csproj"); + + assert_eq!(scan_mtp_kind_in_file(&csproj), MtpProjectKind::VsTestBridge); + } + + #[test] + fn test_both_mtp_properties_in_same_file_still_vstest_bridge() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let csproj = temp_dir.path().join("Hybrid.Tests.csproj"); + fs::write( + &csproj, + r#" + + true + true + +"#, + ) + .expect("write csproj"); + + // All project-file properties → VsTestBridge; only global.json gives MtpNative + assert_eq!(scan_mtp_kind_in_file(&csproj), MtpProjectKind::VsTestBridge); + } + + #[test] + fn test_detect_mode_mtp_csproj_is_vstest_bridge_injects_report_trx() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let csproj = temp_dir.path().join("MTP.Tests.csproj"); + fs::write( + &csproj, + r#" + + true + +"#, + ) + .expect("write csproj"); + + let args = vec![csproj.display().to_string()]; + assert_eq!( + detect_test_runner_mode(&args), + TestRunnerMode::MtpVsTestBridge + ); + + let binlog_path = Path::new("/tmp/test.binlog"); + let injected = build_effective_dotnet_args("test", &args, binlog_path, None); + + // MTP VsTestBridge → --report-trx injected after --, no VSTest --logger trx + assert!(!injected.contains(&"--logger".to_string())); + assert!(injected.contains(&"--report-trx".to_string())); + assert!(injected.contains(&"--".to_string())); + } + + #[test] + fn test_detect_mode_vstest_bridge_injects_report_trx() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let csproj = temp_dir.path().join("MSTest.Tests.csproj"); + fs::write( + &csproj, + r#" + + true + +"#, + ) + .expect("write csproj"); + + let args = vec![csproj.display().to_string()]; + assert_eq!( + detect_test_runner_mode(&args), + TestRunnerMode::MtpVsTestBridge + ); + + let binlog_path = Path::new("/tmp/test.binlog"); + let injected = build_effective_dotnet_args("test", &args, binlog_path, None); + + // --report-trx injected after --, --nologo supported in bridge mode + assert!(!injected.contains(&"--logger".to_string())); + assert!(injected.contains(&"--report-trx".to_string())); + assert!(injected.contains(&"--".to_string())); + assert!(injected.contains(&"-nologo".to_string())); + } + + #[test] + fn test_parse_global_json_mtp_mode_detects_mtp_native() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let global_json = temp_dir.path().join("global.json"); + fs::write( + &global_json, + r#"{"sdk":{"version":"10.0.100"},"test":{"runner":"Microsoft.Testing.Platform"}}"#, + ) + .expect("write global.json"); + + assert!(parse_global_json_mtp_mode(&global_json)); + } + + #[test] + fn test_vstest_bridge_injects_report_trx_after_separator() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let csproj = temp_dir.path().join("MTP.Tests.csproj"); + fs::write( + &csproj, + r#" + + true + +"#, + ) + .expect("write csproj"); + + let args = vec![csproj.display().to_string()]; + assert_eq!( + detect_test_runner_mode(&args), + TestRunnerMode::MtpVsTestBridge + ); + + let binlog_path = Path::new("/tmp/test.binlog"); + let injected = build_effective_dotnet_args("test", &args, binlog_path, None); + + // VsTestBridge → inject -- --report-trx after user args + assert!(injected.contains(&"--".to_string())); + assert!(injected.contains(&"--report-trx".to_string())); + let sep_pos = injected.iter().position(|a| a == "--").unwrap(); + let trx_pos = injected.iter().position(|a| a == "--report-trx").unwrap(); + assert!(sep_pos < trx_pos); + // No VSTest logger + assert!(!injected.contains(&"--logger".to_string())); + } + + #[test] + fn test_vstest_bridge_existing_separator_inserts_report_trx_after_it() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let csproj = temp_dir.path().join("MTP.Tests.csproj"); + fs::write( + &csproj, + r#" + + true + +"#, + ) + .expect("write csproj"); + + let args = vec![ + csproj.display().to_string(), + "--".to_string(), + "--parallel".to_string(), + ]; + let binlog_path = Path::new("/tmp/test.binlog"); + let injected = build_effective_dotnet_args("test", &args, binlog_path, None); + + // --report-trx inserted right after existing -- + let sep_pos = injected.iter().position(|a| a == "--").unwrap(); + assert_eq!(injected[sep_pos + 1], "--report-trx"); + assert!(injected.contains(&"--parallel".to_string())); + } + + #[test] + fn test_vstest_bridge_respects_existing_report_trx() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let csproj = temp_dir.path().join("MTP.Tests.csproj"); + fs::write( + &csproj, + r#" + + true + +"#, + ) + .expect("write csproj"); + + let args = vec![ + csproj.display().to_string(), + "--".to_string(), + "--report-trx".to_string(), + ]; + let binlog_path = Path::new("/tmp/test.binlog"); + let injected = build_effective_dotnet_args("test", &args, binlog_path, None); + + // Should not double-inject + assert_eq!(injected.iter().filter(|a| *a == "--report-trx").count(), 1); + } + + #[test] + fn test_detect_mode_classic_csproj_injects_trx() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let csproj = temp_dir.path().join("Classic.Tests.csproj"); + fs::write( + &csproj, + r#" + + net9.0 + +"#, + ) + .expect("write csproj"); + + let args = vec![csproj.display().to_string()]; + assert_eq!(detect_test_runner_mode(&args), TestRunnerMode::Classic); + + let binlog_path = Path::new("/tmp/test.binlog"); + let trx_dir = Path::new("/tmp/test_results"); + let injected = build_effective_dotnet_args("test", &args, binlog_path, Some(trx_dir)); + assert!(injected.contains(&"--logger".to_string())); + assert!(injected.contains(&"trx".to_string())); + } + + #[test] + fn test_detect_mode_directory_build_props_vstest_bridge() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let props = temp_dir.path().join("Directory.Build.props"); + fs::write( + &props, + r#" + + true + +"#, + ) + .expect("write Directory.Build.props"); + + assert_eq!(scan_mtp_kind_in_file(&props), MtpProjectKind::VsTestBridge); + } + + #[test] + fn test_is_global_json_mtp_mode_detects_mtp_runner() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let global_json = temp_dir.path().join("global.json"); + fs::write( + &global_json, + r#"{ "sdk": { "version": "10.0.100" }, "test": { "runner": "Microsoft.Testing.Platform" } }"#, + ) + .expect("write global.json"); + + assert!(parse_global_json_mtp_mode(&global_json)); + } + + #[test] + fn test_is_global_json_mtp_mode_returns_false_for_vstest_runner() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let global_json = temp_dir.path().join("global.json"); + fs::write(&global_json, r#"{ "sdk": { "version": "9.0.100" } }"#) + .expect("write global.json"); + + assert!(!parse_global_json_mtp_mode(&global_json)); + } + #[test] fn test_merge_test_summary_from_trx_uses_primary_and_cleans_file() { let temp_dir = tempfile::tempdir().expect("create temp dir"); diff --git a/src/filters/bundle-install.toml b/src/filters/bundle-install.toml new file mode 100644 index 00000000..80e07486 --- /dev/null +++ b/src/filters/bundle-install.toml @@ -0,0 +1,61 @@ +[filters.bundle-install] +description = "Compact bundle install/update — strip 'Using' lines, keep installs and errors" +match_command = "^bundle\\s+(install|update)\\b" +strip_ansi = true +strip_lines_matching = [ + "^Using ", + "^\\s*$", + "^Fetching gem metadata", + "^Resolving dependencies", +] +match_output = [ + { pattern = "Bundle complete!", message = "ok bundle: complete" }, + { pattern = "Bundle updated!", message = "ok bundle: updated" }, +] +max_lines = 30 + +[[tests.bundle-install]] +name = "all cached short-circuits" +input = """ +Using bundler 2.5.6 +Using rake 13.1.0 +Using ast 2.4.2 +Using base64 0.2.0 +Using minitest 5.22.2 +Bundle complete! 85 Gemfile dependencies, 200 gems now installed. +Use `bundle info [gemname]` to see where a bundled gem is installed. +""" +expected = "ok bundle: complete" + +[[tests.bundle-install]] +name = "mixed install keeps Fetching and Installing lines" +input = """ +Fetching gem metadata from https://rubygems.org/......... +Resolving dependencies... +Using rake 13.1.0 +Using ast 2.4.2 +Fetching rspec 3.13.0 +Installing rspec 3.13.0 +Using rubocop 1.62.0 +Fetching simplecov 0.22.0 +Installing simplecov 0.22.0 +Bundle complete! 85 Gemfile dependencies, 202 gems now installed. +""" +expected = "ok bundle: complete" + +[[tests.bundle-install]] +name = "update output" +input = """ +Fetching gem metadata from https://rubygems.org/......... +Resolving dependencies... +Using rake 13.1.0 +Fetching rspec 3.14.0 (was 3.13.0) +Installing rspec 3.14.0 (was 3.13.0) +Bundle updated! +""" +expected = "ok bundle: updated" + +[[tests.bundle-install]] +name = "empty output" +input = "" +expected = "" diff --git a/src/filters/stat.toml b/src/filters/stat.toml index 24d9d946..8c240c05 100644 --- a/src/filters/stat.toml +++ b/src/filters/stat.toml @@ -1,21 +1,17 @@ [filters.stat] -description = "Compact stat output — strip blank lines" +description = "Compact stat output — strip device/inode/birth noise" match_command = "^stat\\b" strip_ansi = true strip_lines_matching = [ "^\\s*$", + "^\\s*Device:", + "^\\s*Birth:", ] -max_lines = 30 +truncate_lines_at = 120 +max_lines = 20 [[tests.stat]] -name = "macOS stat output kept" -input = """ -16777234 8690244974 -rw-r--r-- 1 patrick staff 0 12345 "Mar 10 12:00:00 2026" "Mar 10 11:00:00 2026" "Mar 10 11:00:00 2026" "Mar 9 10:00:00 2026" 4096 24 0 file.txt -""" -expected = "16777234 8690244974 -rw-r--r-- 1 patrick staff 0 12345 \"Mar 10 12:00:00 2026\" \"Mar 10 11:00:00 2026\" \"Mar 10 11:00:00 2026\" \"Mar 9 10:00:00 2026\" 4096 24 0 file.txt" - -[[tests.stat]] -name = "linux stat output kept" +name = "linux stat output strips device and birth" input = """ File: main.rs Size: 12345 Blocks: 24 IO Block: 4096 regular file @@ -26,7 +22,21 @@ Modify: 2026-03-10 11:00:00.000000000 +0100 Change: 2026-03-10 11:00:00.000000000 +0100 Birth: 2026-03-09 10:00:00.000000000 +0100 """ -expected = " File: main.rs\n Size: 12345 Blocks: 24 IO Block: 4096 regular file\nDevice: 801h/2049d Inode: 1234567 Links: 1\nAccess: (0644/-rw-r--r--) Uid: ( 1000/ patrick) Gid: ( 1000/ patrick)\nAccess: 2026-03-10 12:00:00.000000000 +0100\nModify: 2026-03-10 11:00:00.000000000 +0100\nChange: 2026-03-10 11:00:00.000000000 +0100\n Birth: 2026-03-09 10:00:00.000000000 +0100" +expected = " File: main.rs\n Size: 12345 Blocks: 24 IO Block: 4096 regular file\nAccess: (0644/-rw-r--r--) Uid: ( 1000/ patrick) Gid: ( 1000/ patrick)\nAccess: 2026-03-10 12:00:00.000000000 +0100\nModify: 2026-03-10 11:00:00.000000000 +0100\nChange: 2026-03-10 11:00:00.000000000 +0100" + +[[tests.stat]] +name = "macOS stat -x strips device and birth" +input = """ + File: "main.rs" + Size: 82848 FileType: Regular File + Mode: (0644/-rw-r--r--) Uid: ( 501/ patrick) Gid: ( 20/ staff) +Device: 1,15 Inode: 66302332 Links: 1 +Access: Wed Mar 18 21:21:15 2026 +Modify: Wed Mar 18 20:56:11 2026 +Change: Wed Mar 18 20:56:11 2026 + Birth: Wed Mar 18 20:56:11 2026 +""" +expected = " File: \"main.rs\"\n Size: 82848 FileType: Regular File\n Mode: (0644/-rw-r--r--) Uid: ( 501/ patrick) Gid: ( 20/ staff)\nAccess: Wed Mar 18 21:21:15 2026\nModify: Wed Mar 18 20:56:11 2026\nChange: Wed Mar 18 20:56:11 2026" [[tests.stat]] name = "empty input passes through" diff --git a/src/gh_cmd.rs b/src/gh_cmd.rs index 9073c7e0..2477bbd6 100644 --- a/src/gh_cmd.rs +++ b/src/gh_cmd.rs @@ -286,7 +286,13 @@ fn list_prs(args: &[String], _verbose: u8, ultra_compact: bool) -> Result<()> { fn should_passthrough_pr_view(extra_args: &[String]) -> bool { extra_args .iter() - .any(|a| a == "--json" || a == "--jq" || a == "--web") + .any(|a| a == "--json" || a == "--jq" || a == "--web" || a == "--comments") +} + +fn should_passthrough_issue_view(extra_args: &[String]) -> bool { + extra_args + .iter() + .any(|a| a == "--json" || a == "--jq" || a == "--web" || a == "--comments") } fn view_pr(args: &[String], _verbose: u8, ultra_compact: bool) -> Result<()> { @@ -680,6 +686,13 @@ fn view_issue(args: &[String], _verbose: u8) -> Result<()> { None => return Err(anyhow::anyhow!("Issue number required")), }; + // Passthrough when --comments, --json, --jq, or --web is present. + // --comments changes the output to include comments which our JSON + // field list doesn't request, causing silent data loss. + if should_passthrough_issue_view(&extra_args) { + return run_passthrough_with_extra("gh", &["issue", "view", &issue_number], &extra_args); + } + let mut cmd = resolved_command("gh"); cmd.args([ "issue", @@ -1105,6 +1118,18 @@ fn pr_merge(args: &[String], _verbose: u8) -> Result<()> { Ok(()) } +/// Flags that change `gh pr diff` output from unified diff to a different format. +/// When present, compact_diff would produce empty output since it expects diff headers. +fn has_non_diff_format_flag(args: &[String]) -> bool { + args.iter().any(|a| { + a == "--name-only" + || a == "--name-status" + || a == "--stat" + || a == "--numstat" + || a == "--shortstat" + }) +} + fn pr_diff(args: &[String], _verbose: u8) -> Result<()> { // --no-compact: pass full diff through (gh CLI doesn't know this flag, strip it) let no_compact = args.iter().any(|a| a == "--no-compact"); @@ -1114,7 +1139,9 @@ fn pr_diff(args: &[String], _verbose: u8) -> Result<()> { .cloned() .collect(); - if no_compact { + // Passthrough when --no-compact or when a format flag changes output away from + // unified diff (e.g. --name-only produces a filename list, not diff hunks). + if no_compact || has_non_diff_format_flag(&gh_args) { return run_passthrough_with_extra("gh", &["pr", "diff"], &gh_args); } @@ -1488,8 +1515,81 @@ mod tests { } #[test] - fn test_should_passthrough_pr_view_other_flags() { - assert!(!should_passthrough_pr_view(&["--comments".into()])); + fn test_should_passthrough_pr_view_comments() { + assert!(should_passthrough_pr_view(&["--comments".into()])); + } + + // --- should_passthrough_issue_view tests --- + + #[test] + fn test_should_passthrough_issue_view_comments() { + assert!(should_passthrough_issue_view(&["--comments".into()])); + } + + #[test] + fn test_should_passthrough_issue_view_json() { + assert!(should_passthrough_issue_view(&[ + "--json".into(), + "body,comments".into() + ])); + } + + #[test] + fn test_should_passthrough_issue_view_jq() { + assert!(should_passthrough_issue_view(&[ + "--jq".into(), + ".body".into() + ])); + } + + #[test] + fn test_should_passthrough_issue_view_web() { + assert!(should_passthrough_issue_view(&["--web".into()])); + } + + #[test] + fn test_should_passthrough_issue_view_default() { + assert!(!should_passthrough_issue_view(&[])); + } + + // --- has_non_diff_format_flag tests --- + + #[test] + fn test_non_diff_format_flag_name_only() { + assert!(has_non_diff_format_flag(&["--name-only".into()])); + } + + #[test] + fn test_non_diff_format_flag_stat() { + assert!(has_non_diff_format_flag(&["--stat".into()])); + } + + #[test] + fn test_non_diff_format_flag_name_status() { + assert!(has_non_diff_format_flag(&["--name-status".into()])); + } + + #[test] + fn test_non_diff_format_flag_numstat() { + assert!(has_non_diff_format_flag(&["--numstat".into()])); + } + + #[test] + fn test_non_diff_format_flag_shortstat() { + assert!(has_non_diff_format_flag(&["--shortstat".into()])); + } + + #[test] + fn test_non_diff_format_flag_absent() { + assert!(!has_non_diff_format_flag(&[])); + } + + #[test] + fn test_non_diff_format_flag_regular_args() { + assert!(!has_non_diff_format_flag(&[ + "123".into(), + "--color=always".into() + ])); } // --- filter_markdown_body tests --- diff --git a/src/git.rs b/src/git.rs index 3d49fdd6..4bb7f674 100644 --- a/src/git.rs +++ b/src/git.rs @@ -297,7 +297,7 @@ pub(crate) fn compact_diff(diff: &str, max_lines: usize) -> String { let mut removed = 0; let mut in_hunk = false; let mut hunk_lines = 0; - let max_hunk_lines = 30; + let max_hunk_lines = 100; let mut was_truncated = false; for line in diff.lines() { @@ -532,17 +532,25 @@ fn filter_log_output( Some(h) => truncate_line(h.trim(), truncate_width), None => continue, }; - // Remaining lines are the body — keep first non-empty line only - let body_line = lines.map(|l| l.trim()).find(|l| { - !l.is_empty() && !l.starts_with("Signed-off-by:") && !l.starts_with("Co-authored-by:") - }); - - match body_line { - Some(body) => { - let truncated_body = truncate_line(body, truncate_width); - result.push(format!("{}\n {}", header, truncated_body)); + // Remaining lines are the body — keep up to 3 non-empty, non-trailer lines + let body_lines: Vec<&str> = lines + .map(|l| l.trim()) + .filter(|l| { + !l.is_empty() + && !l.starts_with("Signed-off-by:") + && !l.starts_with("Co-authored-by:") + }) + .take(3) + .collect(); + + if body_lines.is_empty() { + result.push(header); + } else { + let mut entry = header; + for body in &body_lines { + entry.push_str(&format!("\n {}", truncate_line(body, truncate_width))); } - None => result.push(header), + result.push(entry); } } diff --git a/src/golangci_cmd.rs b/src/golangci_cmd.rs index f6a3166c..b2fdcd28 100644 --- a/src/golangci_cmd.rs +++ b/src/golangci_cmd.rs @@ -15,6 +15,9 @@ struct Position { #[serde(rename = "Column")] #[allow(dead_code)] column: usize, + #[serde(rename = "Offset", default)] + #[allow(dead_code)] + offset: usize, } #[derive(Debug, Deserialize)] @@ -26,6 +29,11 @@ struct Issue { text: String, #[serde(rename = "Pos")] pos: Position, + #[serde(rename = "SourceLines", default)] + source_lines: Vec, + #[serde(rename = "Severity", default)] + #[allow(dead_code)] + severity: String, } #[derive(Debug, Deserialize)] @@ -34,18 +42,63 @@ struct GolangciOutput { issues: Vec, } +/// Parse major version number from `golangci-lint --version` output. +/// Returns 1 on any failure (safe fallback — v1 behaviour). +fn parse_major_version(version_output: &str) -> u32 { + // Handles: + // "golangci-lint version 1.59.1" + // "golangci-lint has version 2.10.0 built with ..." + for word in version_output.split_whitespace() { + if let Some(major) = word.split('.').next().and_then(|s| s.parse::().ok()) { + if word.contains('.') { + return major; + } + } + } + 1 +} + +/// Run `golangci-lint --version` and return the major version number. +/// Returns 1 on any failure. +fn detect_major_version() -> u32 { + let output = resolved_command("golangci-lint").arg("--version").output(); + + match output { + Ok(o) => { + let stdout = String::from_utf8_lossy(&o.stdout); + let stderr = String::from_utf8_lossy(&o.stderr); + let version_text = if stdout.trim().is_empty() { + &*stderr + } else { + &*stdout + }; + parse_major_version(version_text) + } + Err(_) => 1, + } +} + pub fn run(args: &[String], verbose: u8) -> Result<()> { let timer = tracking::TimedExecution::start(); + let version = detect_major_version(); + let mut cmd = resolved_command("golangci-lint"); - // Force JSON output - let has_format = args - .iter() - .any(|a| a == "--out-format" || a.starts_with("--out-format=")); + // Force JSON output (only if user hasn't specified it) + let has_format = args.iter().any(|a| { + a == "--out-format" + || a.starts_with("--out-format=") + || a == "--output.json.path" + || a.starts_with("--output.json.path=") + }); if !has_format { - cmd.arg("run").arg("--out-format=json"); + if version >= 2 { + cmd.arg("run").arg("--output.json.path").arg("stdout"); + } else { + cmd.arg("run").arg("--out-format=json"); + } } else { cmd.arg("run"); } @@ -55,7 +108,11 @@ pub fn run(args: &[String], verbose: u8) -> Result<()> { } if verbose > 0 { - eprintln!("Running: golangci-lint run --out-format=json"); + if version >= 2 { + eprintln!("Running: golangci-lint run --output.json.path stdout"); + } else { + eprintln!("Running: golangci-lint run --out-format=json"); + } } let output = cmd.output().context( @@ -66,12 +123,19 @@ pub fn run(args: &[String], verbose: u8) -> Result<()> { let stderr = String::from_utf8_lossy(&output.stderr); let raw = format!("{}\n{}", stdout, stderr); - let filtered = filter_golangci_json(&stdout); + // v2 outputs JSON on first line + trailing text; v1 outputs just JSON + let json_output = if version >= 2 { + stdout.lines().next().unwrap_or("") + } else { + &*stdout + }; + + let filtered = filter_golangci_json(json_output, version); println!("{}", filtered); - // Include stderr if present (config errors, etc.) - if !stderr.trim().is_empty() && verbose > 0 { + // Always forward stderr (config errors, missing linters, etc.) + if !stderr.trim().is_empty() { eprintln!("{}", stderr.trim()); } @@ -87,9 +151,6 @@ pub fn run(args: &[String], verbose: u8) -> Result<()> { match output.status.code() { Some(0) | Some(1) => Ok(()), Some(code) => { - if !stderr.trim().is_empty() { - eprintln!("{}", stderr.trim()); - } std::process::exit(code); } None => { @@ -100,13 +161,12 @@ pub fn run(args: &[String], verbose: u8) -> Result<()> { } /// Filter golangci-lint JSON output - group by linter and file -fn filter_golangci_json(output: &str) -> String { +fn filter_golangci_json(output: &str, version: u32) -> String { let result: Result = serde_json::from_str(output); let golangci_output = match result { Ok(o) => o, Err(e) => { - // Fallback if JSON parsing fails return format!( "golangci-lint (JSON parse failed: {})\n{}", e, @@ -137,7 +197,7 @@ fn filter_golangci_json(output: &str) -> String { // Group by file let mut by_file: HashMap<&str, usize> = HashMap::new(); for issue in &issues { - *by_file.entry(&issue.pos.filename).or_insert(0) += 1; + *by_file.entry(issue.pos.filename.as_str()).or_insert(0) += 1; } let mut file_counts: Vec<_> = by_file.iter().collect(); @@ -170,16 +230,33 @@ fn filter_golangci_json(output: &str) -> String { result.push_str(&format!(" {} ({} issues)\n", short_path, count)); // Show top 3 linters in this file - let mut file_linters: HashMap = HashMap::new(); - for issue in issues.iter().filter(|i| &i.pos.filename == *file) { - *file_linters.entry(issue.from_linter.clone()).or_insert(0) += 1; + let mut file_linters: HashMap> = HashMap::new(); + for issue in issues.iter().filter(|i| i.pos.filename.as_str() == **file) { + file_linters + .entry(issue.from_linter.clone()) + .or_default() + .push(issue); } let mut file_linter_counts: Vec<_> = file_linters.iter().collect(); - file_linter_counts.sort_by(|a, b| b.1.cmp(a.1)); - - for (linter, count) in file_linter_counts.iter().take(3) { - result.push_str(&format!(" {} ({})\n", linter, count)); + file_linter_counts.sort_by(|a, b| b.1.len().cmp(&a.1.len())); + + for (linter, linter_issues) in file_linter_counts.iter().take(3) { + result.push_str(&format!(" {} ({})\n", linter, linter_issues.len())); + + // v2 only: show first source line for this linter-file group + if version >= 2 { + if let Some(first_issue) = linter_issues.first() { + if let Some(source_line) = first_issue.source_lines.first() { + let trimmed = source_line.trim(); + let display = match trimmed.char_indices().nth(80) { + Some((i, _)) => &trimmed[..i], + None => trimmed, + }; + result.push_str(&format!(" → {}\n", display)); + } + } + } } } @@ -214,7 +291,7 @@ mod tests { #[test] fn test_filter_golangci_no_issues() { let output = r#"{"Issues":[]}"#; - let result = filter_golangci_json(output); + let result = filter_golangci_json(output, 1); assert!(result.contains("golangci-lint")); assert!(result.contains("No issues found")); } @@ -241,7 +318,7 @@ mod tests { ] }"#; - let result = filter_golangci_json(output); + let result = filter_golangci_json(output, 1); assert!(result.contains("3 issues")); assert!(result.contains("2 files")); assert!(result.contains("errcheck")); @@ -266,4 +343,183 @@ mod tests { ); assert_eq!(compact_path("relative/file.go"), "file.go"); } + + #[test] + fn test_parse_version_v1_format() { + assert_eq!(parse_major_version("golangci-lint version 1.59.1"), 1); + } + + #[test] + fn test_parse_version_v2_format() { + assert_eq!( + parse_major_version("golangci-lint has version 2.10.0 built with go1.26.0 from 95dcb68a on 2026-02-17T13:05:51Z"), + 2 + ); + } + + #[test] + fn test_parse_version_empty_returns_1() { + assert_eq!(parse_major_version(""), 1); + } + + #[test] + fn test_parse_version_malformed_returns_1() { + assert_eq!(parse_major_version("not a version string"), 1); + } + + #[test] + fn test_filter_golangci_v2_fields_parse_cleanly() { + // v2 JSON includes Severity, SourceLines, Offset — must not panic + let output = r#"{ + "Issues": [ + { + "FromLinter": "errcheck", + "Text": "Error return value not checked", + "Severity": "error", + "SourceLines": [" if err := foo(); err != nil {"], + "Pos": {"Filename": "main.go", "Line": 42, "Column": 5, "Offset": 1024} + } + ] +}"#; + let result = filter_golangci_json(output, 2); + assert!(result.contains("errcheck")); + assert!(result.contains("main.go")); + } + + #[test] + fn test_filter_v2_shows_source_lines() { + let output = r#"{ + "Issues": [ + { + "FromLinter": "errcheck", + "Text": "Error return value not checked", + "Severity": "error", + "SourceLines": [" if err := foo(); err != nil {"], + "Pos": {"Filename": "main.go", "Line": 42, "Column": 5, "Offset": 0} + } + ] +}"#; + let result = filter_golangci_json(output, 2); + assert!( + result.contains("→"), + "v2 should show source line with → prefix" + ); + assert!(result.contains("if err := foo()")); + } + + #[test] + fn test_filter_v1_does_not_show_source_lines() { + let output = r#"{ + "Issues": [ + { + "FromLinter": "errcheck", + "Text": "Error return value not checked", + "Severity": "error", + "SourceLines": [" if err := foo(); err != nil {"], + "Pos": {"Filename": "main.go", "Line": 42, "Column": 5, "Offset": 0} + } + ] +}"#; + let result = filter_golangci_json(output, 1); + assert!(!result.contains("→"), "v1 should not show source lines"); + } + + #[test] + fn test_filter_v2_empty_source_lines_graceful() { + let output = r#"{ + "Issues": [ + { + "FromLinter": "errcheck", + "Text": "Error return value not checked", + "Severity": "", + "SourceLines": [], + "Pos": {"Filename": "main.go", "Line": 42, "Column": 5, "Offset": 0} + } + ] +}"#; + let result = filter_golangci_json(output, 2); + assert!(result.contains("errcheck")); + assert!( + !result.contains("→"), + "no source line to show, should degrade gracefully" + ); + } + + #[test] + fn test_filter_v2_source_line_truncated_to_80_chars() { + let long_line = "x".repeat(120); + let output = format!( + r#"{{ + "Issues": [ + {{ + "FromLinter": "lll", + "Text": "line too long", + "Severity": "", + "SourceLines": ["{}"], + "Pos": {{"Filename": "main.go", "Line": 1, "Column": 1, "Offset": 0}} + }} + ] +}}"#, + long_line + ); + let result = filter_golangci_json(&output, 2); + // Content truncated at 80 chars; prefix " → " = 10 bytes (6 spaces + 3-byte arrow + space) + // Total line max = 80 + 10 = 90 bytes + for line in result.lines() { + if line.trim_start().starts_with('→') { + assert!(line.len() <= 90, "source line too long: {}", line.len()); + } + } + } + + #[test] + fn test_filter_v2_source_line_truncated_non_ascii() { + // Japanese characters are 3 bytes each; 30 chars = 90 bytes > 80 bytes naive slice would panic + let long_line = "日".repeat(30); // 30 chars, 90 bytes + let output = format!( + r#"{{ + "Issues": [ + {{ + "FromLinter": "lll", + "Text": "line too long", + "Severity": "", + "SourceLines": ["{}"], + "Pos": {{"Filename": "main.go", "Line": 1, "Column": 1, "Offset": 0}} + }} + ] +}}"#, + long_line + ); + // Should not panic and output should be ≤ 80 chars + let result = filter_golangci_json(&output, 2); + for line in result.lines() { + if line.trim_start().starts_with('→') { + let content = line.trim_start().trim_start_matches('→').trim(); + assert!( + content.chars().count() <= 80, + "content chars: {}", + content.chars().count() + ); + } + } + } + + fn count_tokens(text: &str) -> usize { + text.split_whitespace().count() + } + + #[test] + fn test_golangci_v2_token_savings() { + let raw = include_str!("../tests/fixtures/golangci_v2_json.txt"); + + let filtered = filter_golangci_json(raw, 2); + let savings = 100.0 - (count_tokens(&filtered) as f64 / count_tokens(raw) as f64 * 100.0); + + assert!( + savings >= 60.0, + "Expected ≥60% token savings, got {:.1}%\nFiltered output:\n{}", + savings, + filtered + ); + } } diff --git a/src/init.rs b/src/init.rs index 241a7ef5..494bef34 100644 --- a/src/init.rs +++ b/src/init.rs @@ -279,6 +279,11 @@ pub fn run( install_cursor_hooks(verbose)?; } + // Telemetry notice (shown once during init) + println!(); + println!(" [info] Anonymous telemetry is enabled (opt-out: RTK_TELEMETRY_DISABLED=1)"); + println!(" [info] See: https://github.com/rtk-ai/rtk#privacy--telemetry"); + Ok(()) } diff --git a/src/json_cmd.rs b/src/json_cmd.rs index 76bae3ae..685c8f62 100644 --- a/src/json_cmd.rs +++ b/src/json_cmd.rs @@ -33,8 +33,8 @@ fn validate_json_extension(file: &Path) -> Result<()> { Ok(()) } -/// Show JSON structure without values -pub fn run(file: &Path, max_depth: usize, verbose: u8) -> Result<()> { +/// Show JSON (compact with values, or schema-only with --schema) +pub fn run(file: &Path, max_depth: usize, schema_only: bool, verbose: u8) -> Result<()> { validate_json_extension(file)?; let timer = tracking::TimedExecution::start(); @@ -45,19 +45,23 @@ pub fn run(file: &Path, max_depth: usize, verbose: u8) -> Result<()> { let content = fs::read_to_string(file) .with_context(|| format!("Failed to read file: {}", file.display()))?; - let schema = filter_json_string(&content, max_depth)?; - println!("{}", schema); + let output = if schema_only { + filter_json_string(&content, max_depth)? + } else { + filter_json_compact(&content, max_depth)? + }; + println!("{}", output); timer.track( &format!("cat {}", file.display()), "rtk json", &content, - &schema, + &output, ); Ok(()) } -/// Show JSON structure from stdin -pub fn run_stdin(max_depth: usize, verbose: u8) -> Result<()> { +/// Show JSON from stdin +pub fn run_stdin(max_depth: usize, schema_only: bool, verbose: u8) -> Result<()> { let timer = tracking::TimedExecution::start(); if verbose > 0 { @@ -70,13 +74,107 @@ pub fn run_stdin(max_depth: usize, verbose: u8) -> Result<()> { .read_to_string(&mut content) .context("Failed to read from stdin")?; - let schema = filter_json_string(&content, max_depth)?; - println!("{}", schema); - timer.track("cat - (stdin)", "rtk json -", &content, &schema); + let output = if schema_only { + filter_json_string(&content, max_depth)? + } else { + filter_json_compact(&content, max_depth)? + }; + println!("{}", output); + timer.track("cat - (stdin)", "rtk json -", &content, &output); Ok(()) } -/// Parse a JSON string and return its schema representation. +/// Parse a JSON string and return compact representation with values preserved. +/// Long strings are truncated, arrays are summarized. +pub fn filter_json_compact(json_str: &str, max_depth: usize) -> Result { + let value: Value = serde_json::from_str(json_str).context("Failed to parse JSON")?; + Ok(compact_json(&value, 0, max_depth)) +} + +fn compact_json(value: &Value, depth: usize, max_depth: usize) -> String { + let indent = " ".repeat(depth); + + if depth > max_depth { + return format!("{}...", indent); + } + + match value { + Value::Null => format!("{}null", indent), + Value::Bool(b) => format!("{}{}", indent, b), + Value::Number(n) => format!("{}{}", indent, n), + Value::String(s) => { + if s.len() > 80 { + format!("{}\"{}...\"", indent, &s[..77]) + } else { + format!("{}\"{}\"", indent, s) + } + } + Value::Array(arr) => { + if arr.is_empty() { + format!("{}[]", indent) + } else if arr.len() > 5 { + let first = compact_json(&arr[0], depth + 1, max_depth); + format!("{}[{}, ... +{} more]", indent, first.trim(), arr.len() - 1) + } else { + let items: Vec = arr + .iter() + .map(|v| compact_json(v, depth + 1, max_depth)) + .collect(); + let all_simple = arr.iter().all(|v| { + matches!( + v, + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) + ) + }); + if all_simple { + let inline: Vec<&str> = items.iter().map(|s| s.trim()).collect(); + format!("{}[{}]", indent, inline.join(", ")) + } else { + let mut lines = vec![format!("{}[", indent)]; + for item in &items { + lines.push(format!("{},", item)); + } + lines.push(format!("{}]", indent)); + lines.join("\n") + } + } + } + Value::Object(map) => { + if map.is_empty() { + format!("{}{{}}", indent) + } else { + let mut lines = vec![format!("{}{{", indent)]; + let mut keys: Vec<_> = map.keys().collect(); + keys.sort(); + + for (i, key) in keys.iter().enumerate() { + let val = &map[*key]; + let is_simple = matches!( + val, + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) + ); + + if is_simple { + let val_str = compact_json(val, 0, max_depth); + lines.push(format!("{} {}: {}", indent, key, val_str.trim())); + } else { + lines.push(format!("{} {}:", indent, key)); + lines.push(compact_json(val, depth + 1, max_depth)); + } + + if i >= 20 { + lines.push(format!("{} ... +{} more keys", indent, keys.len() - i - 1)); + break; + } + } + lines.push(format!("{}}}", indent)); + lines.join("\n") + } + } + } +} + +/// Parse a JSON string and return its schema representation (types only, no values). /// Useful for piping JSON from other commands (e.g., `gh api`, `curl`). pub fn filter_json_string(json_str: &str, max_depth: usize) -> Result { let value: Value = serde_json::from_str(json_str).context("Failed to parse JSON")?; diff --git a/src/main.rs b/src/main.rs index 2bbc4bb2..0ff5124c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -46,8 +46,11 @@ mod prettier_cmd; mod prisma_cmd; mod psql_cmd; mod pytest_cmd; +mod rake_cmd; mod read; mod rewrite_cmd; +mod rspec_cmd; +mod rubocop_cmd; mod ruff_cmd; mod runner; mod session_cmd; @@ -237,13 +240,16 @@ enum Commands { command: Vec, }, - /// Show JSON structure without values + /// Show JSON (compact values, or schema-only with --schema) Json { /// JSON file file: PathBuf, /// Max depth #[arg(short, long, default_value = "5")] depth: usize, + /// Show structure only (strip all values) + #[arg(long)] + schema: bool, }, /// Summarize project dependencies @@ -387,9 +393,9 @@ enum Commands { Wget { /// URL to download url: String, - /// Output to stdout instead of file - #[arg(short = 'O', long)] - stdout: bool, + /// Output file (-O - for stdout) + #[arg(short = 'O', long = "output-document", allow_hyphen_values = true)] + output: Option, /// Additional wget arguments #[arg(trailing_var_arg = true, allow_hyphen_values = true)] args: Vec, @@ -641,6 +647,27 @@ enum Commands { args: Vec, }, + /// Rake/Rails test with compact Minitest output (Ruby) + Rake { + /// Rake arguments (e.g., test, test TEST=path/to/test.rb) + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + + /// RuboCop linter with compact output (Ruby) + Rubocop { + /// RuboCop arguments (e.g., --auto-correct, -A) + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + + /// RSpec test runner with compact output (Rails/Ruby) + Rspec { + /// RSpec arguments (e.g., spec/models, --tag focus) + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Pip package manager with compact output (auto-detects uv) Pip { /// Pip arguments (e.g., list, outdated, install) @@ -1501,11 +1528,15 @@ fn main() -> Result<()> { runner::run_test(&cmd, cli.verbose)?; } - Commands::Json { file, depth } => { + Commands::Json { + file, + depth, + schema, + } => { if file == Path::new("-") { - json_cmd::run_stdin(depth, cli.verbose)?; + json_cmd::run_stdin(depth, schema, cli.verbose)?; } else { - json_cmd::run(&file, depth, cli.verbose)?; + json_cmd::run(&file, depth, schema, cli.verbose)?; } } @@ -1702,11 +1733,18 @@ fn main() -> Result<()> { } } - Commands::Wget { url, stdout, args } => { - if stdout { + Commands::Wget { url, output, args } => { + if output.as_deref() == Some("-") { wget_cmd::run_stdout(&url, &args, cli.verbose)?; } else { - wget_cmd::run(&url, &args, cli.verbose)?; + // Pass -O through to wget via args + let mut all_args = Vec::new(); + if let Some(out_file) = &output { + all_args.push("-O".to_string()); + all_args.push(out_file.clone()); + } + all_args.extend(args); + wget_cmd::run(&url, &all_args, cli.verbose)?; } } @@ -1986,6 +2024,18 @@ fn main() -> Result<()> { mypy_cmd::run(&args, cli.verbose)?; } + Commands::Rake { args } => { + rake_cmd::run(&args, cli.verbose)?; + } + + Commands::Rubocop { args } => { + rubocop_cmd::run(&args, cli.verbose)?; + } + + Commands::Rspec { args } => { + rspec_cmd::run(&args, cli.verbose)?; + } + Commands::Pip { args } => { pip_cmd::run(&args, cli.verbose)?; } @@ -2245,6 +2295,9 @@ fn is_operational_command(cmd: &Commands) -> bool { | Commands::Curl { .. } | Commands::Ruff { .. } | Commands::Pytest { .. } + | Commands::Rake { .. } + | Commands::Rubocop { .. } + | Commands::Rspec { .. } | Commands::Pip { .. } | Commands::Go { .. } | Commands::GolangciLint { .. } diff --git a/src/parser/formatter.rs b/src/parser/formatter.rs index bf9f693e..b41280e2 100644 --- a/src/parser/formatter.rs +++ b/src/parser/formatter.rs @@ -51,13 +51,9 @@ impl TokenFormatter for TestResult { lines.push(String::new()); for (idx, failure) in self.failures.iter().enumerate().take(5) { lines.push(format!("{}. {}", idx + 1, failure.test_name)); - let error_preview: String = failure - .error_message - .lines() - .take(2) - .collect::>() - .join(" "); - lines.push(format!(" {}", error_preview)); + for line in failure.error_message.lines() { + lines.push(format!(" {}", line)); + } } if self.failures.len() > 5 { @@ -334,3 +330,88 @@ impl TokenFormatter for BuildOutput { ) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::parser::types::{TestFailure, TestResult}; + + fn make_failure(name: &str, error: &str) -> TestFailure { + TestFailure { + test_name: name.to_string(), + file_path: "tests/e2e.spec.ts".to_string(), + error_message: error.to_string(), + stack_trace: None, + } + } + + fn make_result(passed: usize, failures: Vec) -> TestResult { + TestResult { + total: passed + failures.len(), + passed, + failed: failures.len(), + skipped: 0, + duration_ms: Some(1500), + failures, + } + } + + // RED: format_compact must show the full error message, not just 2 lines. + // Playwright errors contain the expected/received diff and call log starting + // at line 3+. Truncating to 2 lines leaves the agent with no debug info. + #[test] + fn test_compact_shows_full_error_message() { + let error = "Error: expect(locator).toHaveText(expected)\n\nExpected: 'Submit'\nReceived: 'Loading'\n\nCall log:\n - waiting for getByRole('button', { name: 'Submit' })"; + let result = make_result(5, vec![make_failure("should click submit", error)]); + + let output = result.format_compact(); + + assert!( + output.contains("Expected: 'Submit'"), + "format_compact must preserve expected/received diff\nGot:\n{output}" + ); + assert!( + output.contains("Received: 'Loading'"), + "format_compact must preserve received value\nGot:\n{output}" + ); + assert!( + output.contains("Call log:"), + "format_compact must preserve call log\nGot:\n{output}" + ); + } + + // RED: summary line stays compact regardless of failure detail + #[test] + fn test_compact_summary_line_is_concise() { + let result = make_result(28, vec![make_failure("test", "some error")]); + let output = result.format_compact(); + let first_line = output.lines().next().unwrap_or(""); + assert!( + first_line.contains("28") && first_line.contains("1"), + "First line must show pass/fail counts, got: {first_line}" + ); + } + + // RED: all-pass output stays compact (no failure detail bloat) + #[test] + fn test_compact_all_pass_is_one_line() { + let result = make_result(10, vec![]); + let output = result.format_compact(); + assert!( + output.lines().count() <= 3, + "All-pass output should be compact, got {} lines:\n{output}", + output.lines().count() + ); + } + + // RED: error_message with only 1 line still works (no trailing noise) + #[test] + fn test_compact_single_line_error_no_trailing_noise() { + let result = make_result(0, vec![make_failure("should work", "Timeout exceeded")]); + let output = result.format_compact(); + assert!( + output.contains("Timeout exceeded"), + "Single-line error must appear\nGot:\n{output}" + ); + } +} diff --git a/src/playwright_cmd.rs b/src/playwright_cmd.rs index c553bcc2..ce6f0fe7 100644 --- a/src/playwright_cmd.rs +++ b/src/playwright_cmd.rs @@ -314,7 +314,12 @@ pub fn run(args: &[String], verbose: u8) -> Result<()> { } }; - println!("{}", filtered); + let exit_code = output.status.code().unwrap_or(1); + if let Some(hint) = crate::tee::tee_and_hint(&raw, "playwright", exit_code) { + println!("{}\n{}", filtered, hint); + } else { + println!("{}", filtered); + } timer.track( &format!("playwright {}", args.join(" ")), @@ -325,7 +330,7 @@ pub fn run(args: &[String], verbose: u8) -> Result<()> { // Preserve exit code for CI/CD if !output.status.success() { - std::process::exit(output.status.code().unwrap_or(1)); + std::process::exit(exit_code); } Ok(()) diff --git a/src/rake_cmd.rs b/src/rake_cmd.rs new file mode 100644 index 00000000..e3fba68f --- /dev/null +++ b/src/rake_cmd.rs @@ -0,0 +1,552 @@ +//! Minitest output filter for `rake test` and `rails test`. +//! +//! Parses the standard Minitest output format produced by both `rake test` and +//! `rails test`, filtering down to failures/errors and the summary line. +//! Uses `ruby_exec("rake")` to auto-detect `bundle exec`. + +use crate::tracking; +use crate::utils::{exit_code_from_output, ruby_exec, strip_ansi}; +use anyhow::{Context, Result}; + +/// Decide whether to use `rake test` or `rails test` based on args. +/// +/// `rake test` only supports a single file via `TEST=path` and ignores positional +/// file args. When any positional test file paths are detected, we switch to +/// `rails test` which handles single files, multiple files, and line-number +/// syntax (`file.rb:15`) natively. +fn select_runner(args: &[String]) -> (&'static str, Vec) { + let has_test_subcommand = args.first().map_or(false, |a| a == "test"); + if !has_test_subcommand { + return ("rake", args.to_vec()); + } + + let after_test: Vec<&String> = args[1..].iter().collect(); + + let positional_files: Vec<&&String> = after_test + .iter() + .filter(|a| !a.contains('=') && !a.starts_with('-')) + .filter(|a| looks_like_test_path(a)) + .collect(); + + let needs_rails = !positional_files.is_empty(); + + if needs_rails { + ("rails", args.to_vec()) + } else { + ("rake", args.to_vec()) + } +} + +fn looks_like_test_path(arg: &str) -> bool { + let path = arg.split(':').next().unwrap_or(arg); + path.ends_with(".rb") + || path.starts_with("test/") + || path.starts_with("spec/") + || path.contains("_test.rb") + || path.contains("_spec.rb") +} + +pub fn run(args: &[String], verbose: u8) -> Result<()> { + let timer = tracking::TimedExecution::start(); + + let (tool, effective_args) = select_runner(args); + let mut cmd = ruby_exec(tool); + for arg in &effective_args { + cmd.arg(arg); + } + + if verbose > 0 { + eprintln!( + "Running: {} {}", + cmd.get_program().to_string_lossy(), + effective_args.join(" ") + ); + } + + let output = cmd + .output() + .context("Failed to run rake. Is it installed? Try: gem install rake")?; + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let raw = format!("{}\n{}", stdout, stderr); + + let filtered = filter_minitest_output(&raw); + + let exit_code = exit_code_from_output(&output, "rake"); + if let Some(hint) = crate::tee::tee_and_hint(&raw, "rake", exit_code) { + println!("{}\n{}", filtered, hint); + } else { + println!("{}", filtered); + } + + if !stderr.trim().is_empty() && verbose > 0 { + eprintln!("{}", stderr.trim()); + } + + timer.track( + &format!("rake {}", args.join(" ")), + &format!("rtk rake {}", args.join(" ")), + &raw, + &filtered, + ); + + if !output.status.success() { + std::process::exit(exit_code); + } + + Ok(()) +} + +#[derive(Debug, PartialEq)] +enum ParseState { + Header, + Running, + Failures, + #[allow(dead_code)] + Summary, +} + +/// Parse Minitest output using a state machine. +/// +/// Minitest produces output like: +/// ```text +/// Run options: --seed 12345 +/// +/// # Running: +/// +/// ..F..E.. +/// +/// Finished in 0.123456s, 64.8 runs/s +/// +/// 1) Failure: +/// TestSomething#test_that_fails [/path/to/test.rb:15]: +/// Expected: true +/// Actual: false +/// +/// 8 runs, 7 assertions, 1 failures, 1 errors, 0 skips +/// ``` +fn filter_minitest_output(output: &str) -> String { + let clean = strip_ansi(output); + let mut state = ParseState::Header; + let mut failures: Vec = Vec::new(); + let mut current_failure: Vec = Vec::new(); + let mut summary_line = String::new(); + + for line in clean.lines() { + let trimmed = line.trim(); + + // Detect summary line anywhere (it's always last meaningful line) + // Handles both "N runs, N assertions, ..." and "N tests, N assertions, ..." + if (trimmed.contains(" runs,") || trimmed.contains(" tests,")) + && trimmed.contains(" assertions,") + { + summary_line = trimmed.to_string(); + continue; + } + + // State transitions — handle both standard Minitest and minitest-reporters + if trimmed == "# Running:" || trimmed.starts_with("Started with run options") { + state = ParseState::Running; + continue; + } + + if trimmed.starts_with("Finished in ") { + state = ParseState::Failures; + continue; + } + + match state { + ParseState::Header | ParseState::Running => { + // Skip seed line, blank lines, progress dots + continue; + } + ParseState::Failures => { + if is_failure_header(trimmed) { + if !current_failure.is_empty() { + failures.push(current_failure.join("\n")); + current_failure.clear(); + } + current_failure.push(trimmed.to_string()); + } else if trimmed.is_empty() && !current_failure.is_empty() { + failures.push(current_failure.join("\n")); + current_failure.clear(); + } else if !trimmed.is_empty() { + current_failure.push(line.to_string()); + } + } + ParseState::Summary => {} + } + } + + // Save last failure if any + if !current_failure.is_empty() { + failures.push(current_failure.join("\n")); + } + + build_minitest_summary(&summary_line, &failures) +} + +fn is_failure_header(line: &str) -> bool { + lazy_static::lazy_static! { + static ref RE_FAILURE: regex::Regex = + regex::Regex::new(r"^\d+\)\s+(Failure|Error):$").unwrap(); + } + RE_FAILURE.is_match(line) +} + +fn build_minitest_summary(summary: &str, failures: &[String]) -> String { + let (runs, _assertions, fail_count, error_count, skips) = parse_minitest_summary(summary); + + if runs == 0 && summary.is_empty() { + return "rake test: no tests ran".to_string(); + } + + if fail_count == 0 && error_count == 0 { + let mut msg = format!("ok rake test: {} runs, 0 failures", runs); + if skips > 0 { + msg.push_str(&format!(", {} skips", skips)); + } + return msg; + } + + let mut result = String::new(); + result.push_str(&format!( + "rake test: {} runs, {} failures, {} errors", + runs, fail_count, error_count + )); + if skips > 0 { + result.push_str(&format!(", {} skips", skips)); + } + result.push('\n'); + + if failures.is_empty() { + return result.trim().to_string(); + } + + result.push('\n'); + + for (i, failure) in failures.iter().take(10).enumerate() { + let lines: Vec<&str> = failure.lines().collect(); + // First line is like " 1) Failure:" or " 1) Error:" + if let Some(header) = lines.first() { + result.push_str(&format!("{}. {}\n", i + 1, header.trim())); + } + // Remaining lines contain test name, file:line, assertion message + for line in lines.iter().skip(1).take(4) { + let trimmed = line.trim(); + if !trimmed.is_empty() { + result.push_str(&format!(" {}\n", crate::utils::truncate(trimmed, 120))); + } + } + if i < failures.len().min(10) - 1 { + result.push('\n'); + } + } + + if failures.len() > 10 { + result.push_str(&format!("\n... +{} more failures\n", failures.len() - 10)); + } + + result.trim().to_string() +} + +fn parse_minitest_summary(summary: &str) -> (usize, usize, usize, usize, usize) { + let mut runs = 0; + let mut assertions = 0; + let mut failures = 0; + let mut errors = 0; + let mut skips = 0; + + for part in summary.split(',') { + let part = part.trim(); + let words: Vec<&str> = part.split_whitespace().collect(); + if words.len() >= 2 { + if let Ok(n) = words[0].parse::() { + match words[1].trim_end_matches(',') { + "runs" | "run" | "tests" | "test" => runs = n, + "assertions" | "assertion" => assertions = n, + "failures" | "failure" => failures = n, + "errors" | "error" => errors = n, + "skips" | "skip" => skips = n, + _ => {} + } + } + } + } + + (runs, assertions, failures, errors, skips) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::utils::count_tokens; + + #[test] + fn test_filter_minitest_all_pass() { + let output = r#"Run options: --seed 12345 + +# Running: + +........ + +Finished in 0.123456s, 64.8 runs/s, 72.9 assertions/s. + +8 runs, 9 assertions, 0 failures, 0 errors, 0 skips"#; + + let result = filter_minitest_output(output); + assert!(result.contains("ok rake test")); + assert!(result.contains("8 runs")); + assert!(result.contains("0 failures")); + } + + #[test] + fn test_filter_minitest_with_failures() { + let output = r#"Run options: --seed 54321 + +# Running: + +..F.... + +Finished in 0.234567s, 29.8 runs/s + + 1) Failure: +TestSomething#test_that_fails [/path/to/test.rb:15]: +Expected: true + Actual: false + +7 runs, 7 assertions, 1 failures, 0 errors, 0 skips"#; + + let result = filter_minitest_output(output); + assert!(result.contains("1 failures")); + assert!(result.contains("test_that_fails")); + assert!(result.contains("Expected: true")); + } + + #[test] + fn test_filter_minitest_with_errors() { + let output = r#"Run options: --seed 99999 + +# Running: + +.E.... + +Finished in 0.345678s, 17.4 runs/s + + 1) Error: +TestOther#test_boom [/path/to/test.rb:42]: +RuntimeError: something went wrong + /path/to/test.rb:42:in `test_boom' + +6 runs, 5 assertions, 0 failures, 1 errors, 0 skips"#; + + let result = filter_minitest_output(output); + assert!(result.contains("1 errors")); + assert!(result.contains("test_boom")); + assert!(result.contains("RuntimeError")); + } + + #[test] + fn test_filter_minitest_empty() { + let result = filter_minitest_output(""); + assert!(result.contains("no tests ran")); + } + + #[test] + fn test_filter_minitest_skip() { + let output = r#"Run options: --seed 11111 + +# Running: + +..S.. + +Finished in 0.100000s, 50.0 runs/s + +5 runs, 4 assertions, 0 failures, 0 errors, 1 skips"#; + + let result = filter_minitest_output(output); + assert!(result.contains("ok rake test")); + assert!(result.contains("1 skips")); + } + + #[test] + fn test_token_savings() { + let mut dots = String::new(); + for _ in 0..20 { + dots.push_str( + "......................................................................\n", + ); + } + let output = format!( + "Run options: --seed 12345\n\n\ + # Running:\n\n\ + {}\n\ + Finished in 2.345678s, 213.4 runs/s, 428.7 assertions/s.\n\n\ + 500 runs, 1003 assertions, 0 failures, 0 errors, 0 skips", + dots + ); + + let input_tokens = count_tokens(&output); + let result = filter_minitest_output(&output); + let output_tokens = count_tokens(&result); + + let savings = 100.0 - (output_tokens as f64 / input_tokens as f64 * 100.0); + assert!( + savings >= 80.0, + "Expected >= 80% savings, got {:.1}% (input: {}, output: {})", + savings, + input_tokens, + output_tokens + ); + } + + #[test] + fn test_parse_minitest_summary() { + assert_eq!( + parse_minitest_summary("8 runs, 9 assertions, 0 failures, 0 errors, 0 skips"), + (8, 9, 0, 0, 0) + ); + assert_eq!( + parse_minitest_summary("5 runs, 4 assertions, 1 failures, 1 errors, 2 skips"), + (5, 4, 1, 1, 2) + ); + // minitest-reporters uses "tests" instead of "runs" + assert_eq!( + parse_minitest_summary("57 tests, 378 assertions, 0 failures, 0 errors, 0 skips"), + (57, 378, 0, 0, 0) + ); + } + + #[test] + fn test_filter_minitest_multiple_failures() { + let output = r#"Run options: --seed 77777 + +# Running: + +.FF.E. + +Finished in 0.500000s, 12.0 runs/s + + 1) Failure: +TestFoo#test_alpha [/test.rb:10]: +Expected: 1 + Actual: 2 + + 2) Failure: +TestFoo#test_beta [/test.rb:20]: +Expected: "hello" + Actual: "world" + + 3) Error: +TestBar#test_gamma [/test.rb:30]: +NoMethodError: undefined method `blah' + +6 runs, 5 assertions, 2 failures, 1 errors, 0 skips"#; + + let result = filter_minitest_output(output); + assert!(result.contains("2 failures")); + assert!(result.contains("1 errors")); + assert!(result.contains("test_alpha")); + assert!(result.contains("test_beta")); + assert!(result.contains("test_gamma")); + } + + #[test] + fn test_filter_minitest_reporters_format() { + let output = "Started with run options --seed 37764\n\n\ + Progress: |========================================|\n\n\ + Finished in 5.79938s\n\ + 57 tests, 378 assertions, 0 failures, 0 errors, 0 skips"; + + let result = filter_minitest_output(output); + assert!(result.contains("ok rake test")); + assert!(result.contains("57 runs")); + assert!(result.contains("0 failures")); + } + + #[test] + fn test_filter_minitest_with_ansi() { + let output = "\x1b[32mRun options: --seed 12345\x1b[0m\n\n\ + # Running:\n\n\ + \x1b[32m....\x1b[0m\n\n\ + Finished in 0.1s, 40.0 runs/s\n\n\ + 4 runs, 4 assertions, 0 failures, 0 errors, 0 skips"; + + let result = filter_minitest_output(output); + assert!(result.contains("ok rake test")); + assert!(result.contains("4 runs")); + } + + // ── select_runner tests ───────────────────────────── + + fn args(s: &str) -> Vec { + s.split_whitespace().map(String::from).collect() + } + + #[test] + fn test_select_runner_single_file_uses_rake() { + let (tool, _) = select_runner(&args("test TEST=test/models/post_test.rb")); + assert_eq!(tool, "rake"); + } + + #[test] + fn test_select_runner_no_files_uses_rake() { + let (tool, _) = select_runner(&args("test")); + assert_eq!(tool, "rake"); + } + + #[test] + fn test_select_runner_multiple_files_uses_rails() { + let (tool, a) = select_runner(&args( + "test test/models/post_test.rb test/models/user_test.rb", + )); + assert_eq!(tool, "rails"); + assert_eq!( + a, + args("test test/models/post_test.rb test/models/user_test.rb") + ); + } + + #[test] + fn test_select_runner_line_number_uses_rails() { + let (tool, _) = select_runner(&args("test test/models/post_test.rb:15")); + assert_eq!(tool, "rails"); + } + + #[test] + fn test_select_runner_multiple_with_line_numbers() { + let (tool, _) = select_runner(&args( + "test test/models/post_test.rb:15 test/models/user_test.rb:30", + )); + assert_eq!(tool, "rails"); + } + + #[test] + fn test_select_runner_non_test_subcommand_uses_rake() { + let (tool, _) = select_runner(&args("db:migrate")); + assert_eq!(tool, "rake"); + } + + #[test] + fn test_select_runner_single_positional_file_uses_rails() { + let (tool, _) = select_runner(&args("test test/models/post_test.rb")); + assert_eq!(tool, "rails"); + } + + #[test] + fn test_select_runner_flags_not_counted_as_files() { + let (tool, _) = select_runner(&args("test --verbose --seed 12345")); + assert_eq!(tool, "rake"); + } + + #[test] + fn test_looks_like_test_path() { + assert!(looks_like_test_path("test/models/post_test.rb")); + assert!(looks_like_test_path("test/models/post_test.rb:15")); + assert!(looks_like_test_path("spec/models/post_spec.rb")); + assert!(looks_like_test_path("my_file.rb")); + assert!(!looks_like_test_path("--verbose")); + assert!(!looks_like_test_path("12345")); + } +} diff --git a/src/rspec_cmd.rs b/src/rspec_cmd.rs new file mode 100644 index 00000000..3d8bf2c4 --- /dev/null +++ b/src/rspec_cmd.rs @@ -0,0 +1,1046 @@ +//! RSpec test runner filter. +//! +//! Injects `--format json` to get structured output, parses it to show only +//! failures. Falls back to a state-machine text parser when JSON is unavailable +//! (e.g., user specified `--format documentation`) or when injected JSON output +//! fails to parse. + +use crate::tracking; +use crate::utils::{exit_code_from_output, fallback_tail, ruby_exec, truncate}; +use anyhow::{Context, Result}; +use lazy_static::lazy_static; +use regex::Regex; +use serde::Deserialize; + +// ── Noise-stripping regex patterns ────────────────────────────────────────── + +lazy_static! { + static ref RE_SPRING: Regex = Regex::new(r"(?i)running via spring preloader").unwrap(); + static ref RE_SIMPLECOV: Regex = + Regex::new(r"(?i)(coverage report|simplecov|coverage/|\.simplecov|All Files.*Lines)") + .unwrap(); + static ref RE_DEPRECATION: Regex = Regex::new(r"^DEPRECATION WARNING:").unwrap(); + static ref RE_FINISHED_IN: Regex = Regex::new(r"^Finished in \d").unwrap(); + static ref RE_SCREENSHOT: Regex = Regex::new(r"saved screenshot to (.+)").unwrap(); + static ref RE_RSPEC_SUMMARY: Regex = Regex::new(r"(\d+) examples?, (\d+) failures?").unwrap(); +} + +// ── JSON structures matching RSpec's --format json output ─────────────────── + +#[derive(Deserialize)] +struct RspecOutput { + examples: Vec, + summary: RspecSummary, +} + +#[derive(Deserialize)] +struct RspecExample { + full_description: String, + status: String, + file_path: String, + line_number: u32, + exception: Option, +} + +#[derive(Deserialize)] +struct RspecException { + class: String, + message: String, + #[serde(default)] + backtrace: Vec, +} + +#[derive(Deserialize)] +struct RspecSummary { + duration: f64, + example_count: usize, + failure_count: usize, + pending_count: usize, + #[serde(default)] + errors_outside_of_examples_count: usize, +} + +// ── Public entry point ─────────────────────────────────────────────────────── + +pub fn run(args: &[String], verbose: u8) -> Result<()> { + let timer = tracking::TimedExecution::start(); + + let mut cmd = ruby_exec("rspec"); + + // Inject --format json unless the user already specified a format. + // Handles: --format, -f, --format=..., -fj, -fjson, -fdocumentation (from PR #534) + let has_format = args.iter().any(|a| { + a == "--format" + || a == "-f" + || a.starts_with("--format=") + || (a.starts_with("-f") && a.len() > 2 && !a.starts_with("--")) + }); + + if !has_format { + cmd.arg("--format").arg("json"); + } + + cmd.args(args); + + if verbose > 0 { + let injected = if has_format { "" } else { " --format json" }; + eprintln!("Running: rspec{} {}", injected, args.join(" ")); + } + + let output = cmd.output().context( + "Failed to run rspec. Is it installed? Try: gem install rspec or add it to your Gemfile", + )?; + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let raw = format!("{}\n{}", stdout, stderr); + + let exit_code = exit_code_from_output(&output, "rspec"); + + let filtered = if stdout.trim().is_empty() && !output.status.success() { + "RSpec: FAILED (no stdout, see stderr below)".to_string() + } else if has_format { + // User specified format — use text fallback on stripped output + let stripped = strip_noise(&stdout); + filter_rspec_text(&stripped) + } else { + filter_rspec_output(&stdout) + }; + + if let Some(hint) = crate::tee::tee_and_hint(&raw, "rspec", exit_code) { + println!("{}\n{}", filtered, hint); + } else { + println!("{}", filtered); + } + + if !stderr.trim().is_empty() && (!output.status.success() || verbose > 0) { + eprintln!("{}", stderr.trim()); + } + + timer.track( + &format!("rspec {}", args.join(" ")), + &format!("rtk rspec {}", args.join(" ")), + &raw, + &filtered, + ); + + if !output.status.success() { + std::process::exit(exit_code); + } + + Ok(()) +} + +// ── Noise stripping ───────────────────────────────────────────────────────── + +/// Remove noise lines: Spring preloader, SimpleCov, DEPRECATION warnings, +/// "Finished in" timing line, and Capybara screenshot details (keep path only). +fn strip_noise(output: &str) -> String { + let mut result = Vec::new(); + let mut in_simplecov_block = false; + + for line in output.lines() { + let trimmed = line.trim(); + + // Skip Spring preloader messages + if RE_SPRING.is_match(trimmed) { + continue; + } + + // Skip lines starting with "DEPRECATION WARNING:" (single-line only) + if RE_DEPRECATION.is_match(trimmed) { + continue; + } + + // Skip "Finished in N seconds" line + if RE_FINISHED_IN.is_match(trimmed) { + continue; + } + + // SimpleCov block detection: once we see it, skip until blank line + if RE_SIMPLECOV.is_match(trimmed) { + in_simplecov_block = true; + continue; + } + if in_simplecov_block { + if trimmed.is_empty() { + in_simplecov_block = false; + } + continue; + } + + // Capybara screenshots: keep only the path + if let Some(caps) = RE_SCREENSHOT.captures(trimmed) { + if let Some(path) = caps.get(1) { + result.push(format!("[screenshot: {}]", path.as_str().trim())); + continue; + } + } + + result.push(line.to_string()); + } + + result.join("\n") +} + +// ── Output filtering ───────────────────────────────────────────────────────── + +fn filter_rspec_output(output: &str) -> String { + if output.trim().is_empty() { + return "RSpec: No output".to_string(); + } + + // Try parsing as JSON first (happy path when --format json is injected) + if let Ok(rspec) = serde_json::from_str::(output) { + return build_rspec_summary(&rspec); + } + + // Strip noise (Spring, SimpleCov, etc.) and retry JSON parse + let stripped = strip_noise(output); + match serde_json::from_str::(&stripped) { + Ok(rspec) => return build_rspec_summary(&rspec), + Err(e) => { + eprintln!( + "[rtk] rspec: JSON parse failed ({}), using text fallback", + e + ); + } + } + + filter_rspec_text(&stripped) +} + +fn build_rspec_summary(rspec: &RspecOutput) -> String { + let s = &rspec.summary; + + if s.example_count == 0 && s.errors_outside_of_examples_count == 0 { + return "RSpec: No examples found".to_string(); + } + + if s.example_count == 0 && s.errors_outside_of_examples_count > 0 { + return format!( + "RSpec: {} errors outside of examples ({:.2}s)", + s.errors_outside_of_examples_count, s.duration + ); + } + + if s.failure_count == 0 && s.errors_outside_of_examples_count == 0 { + let passed = s.example_count.saturating_sub(s.pending_count); + let mut result = format!("✓ RSpec: {} passed", passed); + if s.pending_count > 0 { + result.push_str(&format!(", {} pending", s.pending_count)); + } + result.push_str(&format!(" ({:.2}s)", s.duration)); + return result; + } + + let passed = s + .example_count + .saturating_sub(s.failure_count + s.pending_count); + let mut result = format!("RSpec: {} passed, {} failed", passed, s.failure_count); + if s.pending_count > 0 { + result.push_str(&format!(", {} pending", s.pending_count)); + } + result.push_str(&format!(" ({:.2}s)\n", s.duration)); + result.push_str("═══════════════════════════════════════\n"); + + let failures: Vec<&RspecExample> = rspec + .examples + .iter() + .filter(|e| e.status == "failed") + .collect(); + + if failures.is_empty() { + return result.trim().to_string(); + } + + result.push_str("\nFailures:\n"); + + for (i, example) in failures.iter().take(5).enumerate() { + result.push_str(&format!( + "{}. ❌ {}\n {}:{}\n", + i + 1, + example.full_description, + example.file_path, + example.line_number + )); + + if let Some(exc) = &example.exception { + let short_class = exc.class.split("::").last().unwrap_or(&exc.class); + let first_msg = exc.message.lines().next().unwrap_or(""); + result.push_str(&format!( + " {}: {}\n", + short_class, + truncate(first_msg, 120) + )); + + // First backtrace line not from gems/rspec internals + for bt in &exc.backtrace { + if !bt.contains("/gems/") && !bt.contains("lib/rspec") { + result.push_str(&format!(" {}\n", truncate(bt, 120))); + break; + } + } + } + + if i < failures.len().min(5) - 1 { + result.push('\n'); + } + } + + if failures.len() > 5 { + result.push_str(&format!("\n... +{} more failures\n", failures.len() - 5)); + } + + result.trim().to_string() +} + +/// State machine text fallback parser for when JSON is unavailable. +fn filter_rspec_text(output: &str) -> String { + #[derive(PartialEq)] + enum State { + Header, + Failures, + FailedExamples, + Summary, + } + + let mut state = State::Header; + let mut failures: Vec = Vec::new(); + let mut current_failure = String::new(); + let mut summary_line = String::new(); + + for line in output.lines() { + let trimmed = line.trim(); + + match state { + State::Header => { + if trimmed == "Failures:" { + state = State::Failures; + } else if trimmed == "Failed examples:" { + state = State::FailedExamples; + } else if RE_RSPEC_SUMMARY.is_match(trimmed) { + summary_line = trimmed.to_string(); + state = State::Summary; + } + } + State::Failures => { + // New failure block starts with numbered pattern like " 1) ..." + if is_numbered_failure(trimmed) { + if !current_failure.trim().is_empty() { + failures.push(compact_failure_block(¤t_failure)); + } + current_failure = trimmed.to_string(); + current_failure.push('\n'); + } else if trimmed == "Failed examples:" { + if !current_failure.trim().is_empty() { + failures.push(compact_failure_block(¤t_failure)); + } + current_failure.clear(); + state = State::FailedExamples; + } else if RE_RSPEC_SUMMARY.is_match(trimmed) { + if !current_failure.trim().is_empty() { + failures.push(compact_failure_block(¤t_failure)); + } + current_failure.clear(); + summary_line = trimmed.to_string(); + state = State::Summary; + } else if !trimmed.is_empty() { + // Skip gem-internal backtrace lines + if is_gem_backtrace(trimmed) { + continue; + } + current_failure.push_str(trimmed); + current_failure.push('\n'); + } + } + State::FailedExamples => { + if RE_RSPEC_SUMMARY.is_match(trimmed) { + summary_line = trimmed.to_string(); + state = State::Summary; + } + // Skip "Failed examples:" section (just rspec commands to re-run) + } + State::Summary => { + break; + } + } + } + + // Capture remaining failure + if !current_failure.trim().is_empty() && state == State::Failures { + failures.push(compact_failure_block(¤t_failure)); + } + + // If we found a summary line, build result + if !summary_line.is_empty() { + if failures.is_empty() { + return format!("RSpec: {}", summary_line); + } + let mut result = format!("RSpec: {}\n", summary_line); + result.push_str("═══════════════════════════════════════\n\n"); + for (i, failure) in failures.iter().take(5).enumerate() { + result.push_str(&format!("{}. ❌ {}\n", i + 1, failure)); + if i < failures.len().min(5) - 1 { + result.push('\n'); + } + } + if failures.len() > 5 { + result.push_str(&format!("\n... +{} more failures\n", failures.len() - 5)); + } + return result.trim().to_string(); + } + + // Fallback: look for summary anywhere + for line in output.lines().rev() { + let t = line.trim(); + if t.contains("example") && (t.contains("failure") || t.contains("pending")) { + return format!("RSpec: {}", t); + } + } + + // Last resort: last 5 lines + fallback_tail(output, "rspec", 5) +} + +/// Check if a line is a numbered failure like "1) User#full_name..." +fn is_numbered_failure(line: &str) -> bool { + let trimmed = line.trim(); + if let Some(pos) = trimmed.find(')') { + let prefix = &trimmed[..pos]; + prefix.chars().all(|c| c.is_ascii_digit()) && !prefix.is_empty() + } else { + false + } +} + +/// Check if a backtrace line is from gems/rspec internals. +fn is_gem_backtrace(line: &str) -> bool { + line.contains("/gems/") + || line.contains("lib/rspec") + || line.contains("lib/ruby/") + || line.contains("vendor/bundle") +} + +/// Compact a failure block: extract key info, strip verbose backtrace. +fn compact_failure_block(block: &str) -> String { + let mut lines: Vec<&str> = block.lines().collect(); + + // Remove empty lines + lines.retain(|l| !l.trim().is_empty()); + + // Extract spec file:line (lines starting with # ./spec/ or # ./test/) + let mut spec_file = String::new(); + let mut kept_lines: Vec = Vec::new(); + + for line in &lines { + let t = line.trim(); + if t.starts_with("# ./spec/") || t.starts_with("# ./test/") { + spec_file = t.trim_start_matches("# ").to_string(); + } else if t.starts_with('#') && (t.contains("/gems/") || t.contains("lib/rspec")) { + // Skip gem backtrace + continue; + } else { + kept_lines.push(t.to_string()); + } + } + + let mut result = kept_lines.join("\n "); + if !spec_file.is_empty() { + result.push_str(&format!("\n {}", spec_file)); + } + result +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::utils::count_tokens; + + fn all_pass_json() -> &'static str { + r#"{ + "version": "3.12.0", + "examples": [ + { + "id": "./spec/models/user_spec.rb[1:1]", + "description": "is valid with valid attributes", + "full_description": "User is valid with valid attributes", + "status": "passed", + "file_path": "./spec/models/user_spec.rb", + "line_number": 5, + "run_time": 0.001234, + "pending_message": null, + "exception": null + }, + { + "id": "./spec/models/user_spec.rb[1:2]", + "description": "validates email format", + "full_description": "User validates email format", + "status": "passed", + "file_path": "./spec/models/user_spec.rb", + "line_number": 12, + "run_time": 0.0008, + "pending_message": null, + "exception": null + } + ], + "summary": { + "duration": 0.015, + "example_count": 2, + "failure_count": 0, + "pending_count": 0, + "errors_outside_of_examples_count": 0 + }, + "summary_line": "2 examples, 0 failures" + }"# + } + + fn with_failures_json() -> &'static str { + r#"{ + "version": "3.12.0", + "examples": [ + { + "id": "./spec/models/user_spec.rb[1:1]", + "description": "is valid", + "full_description": "User is valid", + "status": "passed", + "file_path": "./spec/models/user_spec.rb", + "line_number": 5, + "run_time": 0.001, + "pending_message": null, + "exception": null + }, + { + "id": "./spec/models/user_spec.rb[1:2]", + "description": "saves to database", + "full_description": "User saves to database", + "status": "failed", + "file_path": "./spec/models/user_spec.rb", + "line_number": 10, + "run_time": 0.002, + "pending_message": null, + "exception": { + "class": "RSpec::Expectations::ExpectationNotMetError", + "message": "expected true but got false", + "backtrace": [ + "/usr/local/lib/ruby/gems/3.2.0/gems/rspec-expectations-3.12.0/lib/rspec/expectations/fail_with.rb:37:in `fail_with'", + "./spec/models/user_spec.rb:11:in `block (2 levels) in '" + ] + } + } + ], + "summary": { + "duration": 0.123, + "example_count": 2, + "failure_count": 1, + "pending_count": 0, + "errors_outside_of_examples_count": 0 + }, + "summary_line": "2 examples, 1 failure" + }"# + } + + fn with_pending_json() -> &'static str { + r#"{ + "version": "3.12.0", + "examples": [ + { + "id": "./spec/models/post_spec.rb[1:1]", + "description": "creates a post", + "full_description": "Post creates a post", + "status": "passed", + "file_path": "./spec/models/post_spec.rb", + "line_number": 4, + "run_time": 0.002, + "pending_message": null, + "exception": null + }, + { + "id": "./spec/models/post_spec.rb[1:2]", + "description": "validates title", + "full_description": "Post validates title", + "status": "pending", + "file_path": "./spec/models/post_spec.rb", + "line_number": 8, + "run_time": 0.0, + "pending_message": "Not yet implemented", + "exception": null + } + ], + "summary": { + "duration": 0.05, + "example_count": 2, + "failure_count": 0, + "pending_count": 1, + "errors_outside_of_examples_count": 0 + }, + "summary_line": "2 examples, 0 failures, 1 pending" + }"# + } + + fn large_suite_json() -> &'static str { + r#"{ + "version": "3.12.0", + "examples": [ + {"id":"1","description":"test1","full_description":"Suite test1","status":"passed","file_path":"./spec/a_spec.rb","line_number":1,"run_time":0.01,"pending_message":null,"exception":null}, + {"id":"2","description":"test2","full_description":"Suite test2","status":"passed","file_path":"./spec/a_spec.rb","line_number":2,"run_time":0.01,"pending_message":null,"exception":null}, + {"id":"3","description":"test3","full_description":"Suite test3","status":"passed","file_path":"./spec/a_spec.rb","line_number":3,"run_time":0.01,"pending_message":null,"exception":null}, + {"id":"4","description":"test4","full_description":"Suite test4","status":"passed","file_path":"./spec/a_spec.rb","line_number":4,"run_time":0.01,"pending_message":null,"exception":null}, + {"id":"5","description":"test5","full_description":"Suite test5","status":"passed","file_path":"./spec/a_spec.rb","line_number":5,"run_time":0.01,"pending_message":null,"exception":null}, + {"id":"6","description":"test6","full_description":"Suite test6","status":"passed","file_path":"./spec/a_spec.rb","line_number":6,"run_time":0.01,"pending_message":null,"exception":null}, + {"id":"7","description":"test7","full_description":"Suite test7","status":"passed","file_path":"./spec/a_spec.rb","line_number":7,"run_time":0.01,"pending_message":null,"exception":null}, + {"id":"8","description":"test8","full_description":"Suite test8","status":"passed","file_path":"./spec/a_spec.rb","line_number":8,"run_time":0.01,"pending_message":null,"exception":null}, + {"id":"9","description":"test9","full_description":"Suite test9","status":"passed","file_path":"./spec/a_spec.rb","line_number":9,"run_time":0.01,"pending_message":null,"exception":null}, + {"id":"10","description":"test10","full_description":"Suite test10","status":"passed","file_path":"./spec/a_spec.rb","line_number":10,"run_time":0.01,"pending_message":null,"exception":null} + ], + "summary": { + "duration": 1.234, + "example_count": 10, + "failure_count": 0, + "pending_count": 0, + "errors_outside_of_examples_count": 0 + }, + "summary_line": "10 examples, 0 failures" + }"# + } + + #[test] + fn test_filter_rspec_all_pass() { + let result = filter_rspec_output(all_pass_json()); + assert!(result.starts_with("✓ RSpec:")); + assert!(result.contains("2 passed")); + assert!(result.contains("0.01s") || result.contains("0.02s")); + } + + #[test] + fn test_filter_rspec_with_failures() { + let result = filter_rspec_output(with_failures_json()); + assert!(result.contains("1 passed, 1 failed")); + assert!(result.contains("❌ User saves to database")); + assert!(result.contains("user_spec.rb:10")); + assert!(result.contains("ExpectationNotMetError")); + assert!(result.contains("expected true but got false")); + } + + #[test] + fn test_filter_rspec_with_pending() { + let result = filter_rspec_output(with_pending_json()); + assert!(result.starts_with("✓ RSpec:")); + assert!(result.contains("1 passed")); + assert!(result.contains("1 pending")); + } + + #[test] + fn test_filter_rspec_empty_output() { + let result = filter_rspec_output(""); + assert_eq!(result, "RSpec: No output"); + } + + #[test] + fn test_filter_rspec_no_examples() { + let json = r#"{ + "version": "3.12.0", + "examples": [], + "summary": { + "duration": 0.001, + "example_count": 0, + "failure_count": 0, + "pending_count": 0, + "errors_outside_of_examples_count": 0 + } + }"#; + let result = filter_rspec_output(json); + assert_eq!(result, "RSpec: No examples found"); + } + + #[test] + fn test_filter_rspec_errors_outside_examples() { + let json = r#"{ + "version": "3.12.0", + "examples": [], + "summary": { + "duration": 0.01, + "example_count": 0, + "failure_count": 0, + "pending_count": 0, + "errors_outside_of_examples_count": 1 + } + }"#; + let result = filter_rspec_output(json); + // Should NOT say "No examples found" — there was an error outside examples + assert!( + !result.contains("No examples found"), + "errors outside examples should not be treated as 'no examples': {}", + result + ); + } + + #[test] + fn test_filter_rspec_text_fallback() { + let text = r#" +..F. + +Failures: + + 1) User is valid + Failure/Error: expect(user).to be_valid + expected true got false + # ./spec/models/user_spec.rb:5 + +4 examples, 1 failure +"#; + let result = filter_rspec_output(text); + assert!(result.contains("RSpec:")); + assert!(result.contains("4 examples, 1 failure")); + assert!(result.contains("❌"), "should show failure marker"); + } + + #[test] + fn test_filter_rspec_text_fallback_extracts_failures() { + let text = r#"Randomized with seed 12345 +..F...E.. + +Failures: + + 1) User#full_name returns first and last name + Failure/Error: expect(user.full_name).to eq("John Doe") + expected: "John Doe" + got: "John D." + # /usr/local/lib/ruby/gems/3.2.0/gems/rspec-expectations-3.12.0/lib/rspec/expectations/fail_with.rb:37 + # ./spec/models/user_spec.rb:15 + + 2) Api::Controller#index fails + Failure/Error: get :index + expected 200 got 500 + # ./spec/controllers/api_spec.rb:42 + +9 examples, 2 failures +"#; + let result = filter_rspec_text(text); + assert!(result.contains("2 failures")); + assert!(result.contains("❌")); + // Should show spec file path, not gem backtrace + assert!(result.contains("spec/models/user_spec.rb:15")); + } + + #[test] + fn test_filter_rspec_backtrace_filters_gems() { + let result = filter_rspec_output(with_failures_json()); + // Should show the spec file backtrace, not the gem one + assert!(result.contains("user_spec.rb:11")); + assert!(!result.contains("gems/rspec-expectations")); + } + + #[test] + fn test_filter_rspec_exception_class_shortened() { + let result = filter_rspec_output(with_failures_json()); + // Should show "ExpectationNotMetError" not "RSpec::Expectations::ExpectationNotMetError" + assert!(result.contains("ExpectationNotMetError")); + assert!(!result.contains("RSpec::Expectations::ExpectationNotMetError")); + } + + #[test] + fn test_filter_rspec_many_failures_caps_at_five() { + let json = r#"{ + "version": "3.12.0", + "examples": [ + {"id":"1","description":"test 1","full_description":"A test 1","status":"failed","file_path":"./spec/a_spec.rb","line_number":5,"run_time":0.001,"pending_message":null,"exception":{"class":"RuntimeError","message":"boom 1","backtrace":["./spec/a_spec.rb:6:in `block'"]}}, + {"id":"2","description":"test 2","full_description":"A test 2","status":"failed","file_path":"./spec/a_spec.rb","line_number":10,"run_time":0.001,"pending_message":null,"exception":{"class":"RuntimeError","message":"boom 2","backtrace":["./spec/a_spec.rb:11:in `block'"]}}, + {"id":"3","description":"test 3","full_description":"A test 3","status":"failed","file_path":"./spec/a_spec.rb","line_number":15,"run_time":0.001,"pending_message":null,"exception":{"class":"RuntimeError","message":"boom 3","backtrace":["./spec/a_spec.rb:16:in `block'"]}}, + {"id":"4","description":"test 4","full_description":"A test 4","status":"failed","file_path":"./spec/a_spec.rb","line_number":20,"run_time":0.001,"pending_message":null,"exception":{"class":"RuntimeError","message":"boom 4","backtrace":["./spec/a_spec.rb:21:in `block'"]}}, + {"id":"5","description":"test 5","full_description":"A test 5","status":"failed","file_path":"./spec/a_spec.rb","line_number":25,"run_time":0.001,"pending_message":null,"exception":{"class":"RuntimeError","message":"boom 5","backtrace":["./spec/a_spec.rb:26:in `block'"]}}, + {"id":"6","description":"test 6","full_description":"A test 6","status":"failed","file_path":"./spec/a_spec.rb","line_number":30,"run_time":0.001,"pending_message":null,"exception":{"class":"RuntimeError","message":"boom 6","backtrace":["./spec/a_spec.rb:31:in `block'"]}} + ], + "summary": { + "duration": 0.05, + "example_count": 6, + "failure_count": 6, + "pending_count": 0, + "errors_outside_of_examples_count": 0 + }, + "summary_line": "6 examples, 6 failures" + }"#; + let result = filter_rspec_output(json); + assert!(result.contains("1. ❌"), "should show first failure"); + assert!(result.contains("5. ❌"), "should show fifth failure"); + assert!(!result.contains("6. ❌"), "should not show sixth inline"); + assert!( + result.contains("+1 more"), + "should show overflow count: {}", + result + ); + } + + #[test] + fn test_filter_rspec_text_fallback_no_summary() { + // If no summary line, returns last 5 lines (does not panic) + let text = "some output\nwithout a summary line"; + let result = filter_rspec_output(text); + assert!(!result.is_empty()); + } + + #[test] + fn test_filter_rspec_invalid_json_falls_back() { + let garbage = "not json at all { broken"; + let result = filter_rspec_output(garbage); + assert!(!result.is_empty(), "should not panic on invalid JSON"); + } + + // ── Noise stripping tests ──────────────────────────────────────────────── + + #[test] + fn test_strip_noise_spring() { + let input = "Running via Spring preloader in process 12345\n...\n3 examples, 0 failures"; + let result = strip_noise(input); + assert!(!result.contains("Spring")); + assert!(result.contains("3 examples")); + } + + #[test] + fn test_strip_noise_simplecov() { + let input = "...\n\nCoverage report generated for RSpec to /app/coverage.\n142 / 200 LOC (71.0%) covered.\n\n3 examples, 0 failures"; + let result = strip_noise(input); + assert!(!result.contains("Coverage report")); + assert!(!result.contains("LOC")); + assert!(result.contains("3 examples")); + } + + #[test] + fn test_strip_noise_deprecation() { + let input = "DEPRECATION WARNING: Using `return` in before callbacks is deprecated.\n...\n3 examples, 0 failures"; + let result = strip_noise(input); + assert!(!result.contains("DEPRECATION")); + assert!(result.contains("3 examples")); + } + + #[test] + fn test_strip_noise_finished_in() { + let input = "...\nFinished in 12.34 seconds (files took 3.21 seconds to load)\n3 examples, 0 failures"; + let result = strip_noise(input); + assert!(!result.contains("Finished in 12.34")); + assert!(result.contains("3 examples")); + } + + #[test] + fn test_strip_noise_capybara_screenshot() { + let input = "...\n saved screenshot to /tmp/capybara/screenshots/2026_failed.png\n3 examples, 1 failure"; + let result = strip_noise(input); + assert!(result.contains("[screenshot:")); + assert!(result.contains("failed.png")); + assert!(!result.contains("saved screenshot to")); + } + + // ── Token savings tests ────────────────────────────────────────────────── + + #[test] + fn test_token_savings_all_pass() { + let input = large_suite_json(); + let output = filter_rspec_output(input); + + let input_tokens = count_tokens(input); + let output_tokens = count_tokens(&output); + let savings = 100.0 - (output_tokens as f64 / input_tokens as f64 * 100.0); + + assert!( + savings >= 60.0, + "RSpec all-pass: expected ≥60% savings, got {:.1}% (in={}, out={})", + savings, + input_tokens, + output_tokens + ); + } + + #[test] + fn test_token_savings_with_failures() { + let input = with_failures_json(); + let output = filter_rspec_output(input); + + let input_tokens = count_tokens(input); + let output_tokens = count_tokens(&output); + let savings = 100.0 - (output_tokens as f64 / input_tokens as f64 * 100.0); + + assert!( + savings >= 60.0, + "RSpec failures: expected ≥60% savings, got {:.1}% (in={}, out={})", + savings, + input_tokens, + output_tokens + ); + } + + #[test] + fn test_token_savings_text_fallback() { + let input = r#"Running via Spring preloader in process 12345 +Randomized with seed 54321 +..F...E..F.. + +Failures: + + 1) User#full_name returns first and last name + Failure/Error: expect(user.full_name).to eq("John Doe") + expected: "John Doe" + got: "John D." + # /usr/local/lib/ruby/gems/3.2.0/gems/rspec-expectations-3.12.0/lib/rspec/expectations/fail_with.rb:37 + # ./spec/models/user_spec.rb:15 + # /usr/local/lib/ruby/gems/3.2.0/gems/rspec-core-3.12.0/lib/rspec/core/example.rb:258 + + 2) Api::Controller#index returns success + Failure/Error: get :index + expected 200 got 500 + # /usr/local/lib/ruby/gems/3.2.0/gems/rspec-expectations-3.12.0/lib/rspec/expectations/fail_with.rb:37 + # ./spec/controllers/api_spec.rb:42 + # /usr/local/lib/ruby/gems/3.2.0/gems/rspec-core-3.12.0/lib/rspec/core/example.rb:258 + +Failed examples: + +rspec ./spec/models/user_spec.rb:15 # User#full_name returns first and last name +rspec ./spec/controllers/api_spec.rb:42 # Api::Controller#index returns success + +12 examples, 2 failures + +Coverage report generated for RSpec to /app/coverage. +142 / 200 LOC (71.0%) covered. +"#; + let output = filter_rspec_text(input); + + let input_tokens = count_tokens(input); + let output_tokens = count_tokens(&output); + let savings = 100.0 - (output_tokens as f64 / input_tokens as f64 * 100.0); + + assert!( + savings >= 30.0, + "RSpec text fallback: expected ≥30% savings, got {:.1}% (in={}, out={})", + savings, + input_tokens, + output_tokens + ); + } + + // ── ANSI handling tests ──────────────────────────────────────────────── + + #[test] + fn test_filter_rspec_ansi_wrapped_json() { + // ANSI codes around JSON should fall back to text, not panic + let input = "\x1b[32m{\"version\":\"3.12.0\"\x1b[0m broken json"; + let result = filter_rspec_output(input); + assert!(!result.is_empty(), "should not panic on ANSI-wrapped JSON"); + } + + // ── Text fallback >5 failures truncation (Issue 9) ───────────────────── + + #[test] + fn test_filter_rspec_text_many_failures_caps_at_five() { + let text = r#"Randomized with seed 12345 +.......FFFFFFF + +Failures: + + 1) User#full_name fails + Failure/Error: expect(true).to eq(false) + # ./spec/models/user_spec.rb:5 + + 2) Post#title fails + Failure/Error: expect(true).to eq(false) + # ./spec/models/post_spec.rb:10 + + 3) Comment#body fails + Failure/Error: expect(true).to eq(false) + # ./spec/models/comment_spec.rb:15 + + 4) Session#token fails + Failure/Error: expect(true).to eq(false) + # ./spec/models/session_spec.rb:20 + + 5) Profile#avatar fails + Failure/Error: expect(true).to eq(false) + # ./spec/models/profile_spec.rb:25 + + 6) Team#members fails + Failure/Error: expect(true).to eq(false) + # ./spec/models/team_spec.rb:30 + + 7) Role#permissions fails + Failure/Error: expect(true).to eq(false) + # ./spec/models/role_spec.rb:35 + +14 examples, 7 failures +"#; + let result = filter_rspec_text(text); + assert!(result.contains("1. ❌"), "should show first failure"); + assert!(result.contains("5. ❌"), "should show fifth failure"); + assert!(!result.contains("6. ❌"), "should not show sixth inline"); + assert!( + result.contains("+2 more"), + "should show overflow count: {}", + result + ); + } + + // ── Header -> FailedExamples transition (Issue 13) ────────────────────── + + #[test] + fn test_filter_rspec_text_header_to_failed_examples() { + // Input that has "Failed examples:" directly (no "Failures:" block), + // followed by a summary line + let text = r#"..F.. + +Failed examples: + +rspec ./spec/models/user_spec.rb:5 # User is valid + +5 examples, 1 failure +"#; + let result = filter_rspec_text(text); + assert!( + result.contains("5 examples, 1 failure"), + "should contain summary: {}", + result + ); + assert!( + result.contains("RSpec:"), + "should have RSpec prefix: {}", + result + ); + } + + // ── Format flag detection tests (from PR #534) ─────────────────────── + + #[test] + fn test_has_format_flag_none() { + let args: &[String] = &[]; + assert!(!args.iter().any(|a| { + a == "--format" + || a == "-f" + || a.starts_with("--format=") + || (a.starts_with("-f") && a.len() > 2 && !a.starts_with("--")) + })); + } + + #[test] + fn test_has_format_flag_long() { + let args = ["--format".to_string(), "documentation".to_string()]; + assert!(args.iter().any(|a| a == "--format")); + } + + #[test] + fn test_has_format_flag_short_combined() { + // -fjson, -fj, -fdocumentation + for flag in &["-fjson", "-fj", "-fdocumentation"] { + let args = [flag.to_string()]; + assert!( + args.iter() + .any(|a| a.starts_with("-f") && a.len() > 2 && !a.starts_with("--")), + "should detect {}", + flag + ); + } + } + + #[test] + fn test_has_format_flag_equals() { + let args = ["--format=json".to_string()]; + assert!(args.iter().any(|a| a.starts_with("--format="))); + } +} diff --git a/src/rubocop_cmd.rs b/src/rubocop_cmd.rs new file mode 100644 index 00000000..db2d0ac4 --- /dev/null +++ b/src/rubocop_cmd.rs @@ -0,0 +1,659 @@ +//! RuboCop linter filter. +//! +//! Injects `--format json` for structured output, parses offenses grouped by +//! file and sorted by severity. Falls back to text parsing for autocorrect mode, +//! when the user specifies a custom format, or when injected JSON output fails +//! to parse. + +use crate::tracking; +use crate::utils::{exit_code_from_output, ruby_exec}; +use anyhow::{Context, Result}; +use serde::Deserialize; + +// ── JSON structures matching RuboCop's --format json output ───────────────── + +#[derive(Deserialize)] +struct RubocopOutput { + files: Vec, + summary: RubocopSummary, +} + +#[derive(Deserialize)] +struct RubocopFile { + path: String, + offenses: Vec, +} + +#[derive(Deserialize)] +struct RubocopOffense { + cop_name: String, + severity: String, + message: String, + correctable: bool, + location: RubocopLocation, +} + +#[derive(Deserialize)] +struct RubocopLocation { + start_line: usize, +} + +#[derive(Deserialize)] +struct RubocopSummary { + offense_count: usize, + #[allow(dead_code)] + target_file_count: usize, + inspected_file_count: usize, + #[serde(default)] + correctable_offense_count: usize, +} + +// ── Public entry point ─────────────────────────────────────────────────────── + +pub fn run(args: &[String], verbose: u8) -> Result<()> { + let timer = tracking::TimedExecution::start(); + + let mut cmd = ruby_exec("rubocop"); + + // Detect autocorrect mode + let is_autocorrect = args + .iter() + .any(|a| a == "-a" || a == "-A" || a == "--auto-correct" || a == "--auto-correct-all"); + + // Inject --format json unless the user already specified a format + let has_format = args + .iter() + .any(|a| a.starts_with("--format") || a.starts_with("-f")); + + if !has_format && !is_autocorrect { + cmd.arg("--format").arg("json"); + } + + cmd.args(args); + + if verbose > 0 { + eprintln!("Running: rubocop {}", args.join(" ")); + } + + let output = cmd.output().context( + "Failed to run rubocop. Is it installed? Try: gem install rubocop or add it to your Gemfile", + )?; + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let raw = format!("{}\n{}", stdout, stderr); + + let exit_code = exit_code_from_output(&output, "rubocop"); + + let filtered = if stdout.trim().is_empty() && !output.status.success() { + "RuboCop: FAILED (no stdout, see stderr below)".to_string() + } else if has_format || is_autocorrect { + filter_rubocop_text(&stdout) + } else { + filter_rubocop_json(&stdout) + }; + + if let Some(hint) = crate::tee::tee_and_hint(&raw, "rubocop", exit_code) { + println!("{}\n{}", filtered, hint); + } else { + println!("{}", filtered); + } + + if !stderr.trim().is_empty() && (!output.status.success() || verbose > 0) { + eprintln!("{}", stderr.trim()); + } + + timer.track( + &format!("rubocop {}", args.join(" ")), + &format!("rtk rubocop {}", args.join(" ")), + &raw, + &filtered, + ); + + if !output.status.success() { + std::process::exit(exit_code); + } + + Ok(()) +} + +// ── JSON filtering ─────────────────────────────────────────────────────────── + +/// Rank severity for ordering: lower = more severe. +fn severity_rank(severity: &str) -> u8 { + match severity { + "fatal" | "error" => 0, + "warning" => 1, + "convention" | "refactor" | "info" => 2, + _ => 3, + } +} + +fn filter_rubocop_json(output: &str) -> String { + if output.trim().is_empty() { + return "RuboCop: No output".to_string(); + } + + let parsed: Result = serde_json::from_str(output); + let rubocop = match parsed { + Ok(r) => r, + Err(e) => { + eprintln!("[rtk] rubocop: JSON parse failed ({})", e); + return crate::utils::fallback_tail(output, "rubocop (JSON parse error)", 5); + } + }; + + let s = &rubocop.summary; + + if s.offense_count == 0 { + return format!("ok ✓ rubocop ({} files)", s.inspected_file_count); + } + + // When correctable_offense_count is 0, it could mean the field was absent + // (older RuboCop) or genuinely zero. Manual count as consistent fallback. + let correctable_count = if s.correctable_offense_count > 0 { + s.correctable_offense_count + } else { + rubocop + .files + .iter() + .flat_map(|f| &f.offenses) + .filter(|o| o.correctable) + .count() + }; + + let mut result = format!( + "rubocop: {} offenses ({} files)\n", + s.offense_count, s.inspected_file_count + ); + + // Build list of files with offenses, sorted by worst severity then file path + let mut files_with_offenses: Vec<&RubocopFile> = rubocop + .files + .iter() + .filter(|f| !f.offenses.is_empty()) + .collect(); + + // Sort files: worst severity first, then alphabetically + files_with_offenses.sort_by(|a, b| { + let a_worst = a + .offenses + .iter() + .map(|o| severity_rank(&o.severity)) + .min() + .unwrap_or(3); + let b_worst = b + .offenses + .iter() + .map(|o| severity_rank(&o.severity)) + .min() + .unwrap_or(3); + a_worst.cmp(&b_worst).then(a.path.cmp(&b.path)) + }); + + let max_files = 10; + let max_offenses_per_file = 5; + + for file in files_with_offenses.iter().take(max_files) { + let short = compact_ruby_path(&file.path); + result.push_str(&format!("\n{}\n", short)); + + // Sort offenses within file: by severity rank, then by line number + let mut sorted_offenses: Vec<&RubocopOffense> = file.offenses.iter().collect(); + sorted_offenses.sort_by(|a, b| { + severity_rank(&a.severity) + .cmp(&severity_rank(&b.severity)) + .then(a.location.start_line.cmp(&b.location.start_line)) + }); + + for offense in sorted_offenses.iter().take(max_offenses_per_file) { + let first_msg_line = offense.message.lines().next().unwrap_or(""); + result.push_str(&format!( + " :{} {} — {}\n", + offense.location.start_line, offense.cop_name, first_msg_line + )); + } + if sorted_offenses.len() > max_offenses_per_file { + result.push_str(&format!( + " ... +{} more\n", + sorted_offenses.len() - max_offenses_per_file + )); + } + } + + if files_with_offenses.len() > max_files { + result.push_str(&format!( + "\n... +{} more files\n", + files_with_offenses.len() - max_files + )); + } + + if correctable_count > 0 { + result.push_str(&format!( + "\n({} correctable, run `rubocop -A`)", + correctable_count + )); + } + + result.trim().to_string() +} + +// ── Text fallback ──────────────────────────────────────────────────────────── + +fn filter_rubocop_text(output: &str) -> String { + // Check for Ruby/Bundler errors first -- show error, truncated to avoid excessive tokens + for line in output.lines() { + let t = line.trim(); + if t.contains("cannot load such file") + || t.contains("Bundler::GemNotFound") + || t.contains("Gem::MissingSpecError") + || t.starts_with("rubocop: command not found") + || t.starts_with("rubocop: No such file") + { + let error_lines: Vec<&str> = output.trim().lines().take(20).collect(); + let truncated = error_lines.join("\n"); + let total_lines = output.trim().lines().count(); + if total_lines > 20 { + return format!( + "RuboCop error:\n{}\n... ({} more lines)", + truncated, + total_lines - 20 + ); + } + return format!("RuboCop error:\n{}", truncated); + } + } + + // Detect autocorrect summary: "N files inspected, M offenses detected, K offenses autocorrected" + for line in output.lines().rev() { + let t = line.trim(); + if t.contains("inspected") && t.contains("autocorrected") { + // Extract counts for compact autocorrect message + let files = extract_leading_number(t); + let corrected = extract_autocorrect_count(t); + if files > 0 && corrected > 0 { + return format!( + "ok ✓ rubocop -A ({} files, {} autocorrected)", + files, corrected + ); + } + return format!("RuboCop: {}", t); + } + if t.contains("inspected") && (t.contains("offense") || t.contains("no offenses")) { + if t.contains("no offenses") { + let files = extract_leading_number(t); + if files > 0 { + return format!("ok ✓ rubocop ({} files)", files); + } + return "ok ✓ rubocop (no offenses)".to_string(); + } + return format!("RuboCop: {}", t); + } + } + // Last resort: last 5 lines + crate::utils::fallback_tail(output, "rubocop", 5) +} + +/// Extract leading number from a string like "15 files inspected". +fn extract_leading_number(s: &str) -> usize { + s.split_whitespace() + .next() + .and_then(|w| w.parse().ok()) + .unwrap_or(0) +} + +/// Extract autocorrect count from summary like "... 3 offenses autocorrected". +fn extract_autocorrect_count(s: &str) -> usize { + // Look for "N offenses autocorrected" near end + let parts: Vec<&str> = s.split(',').collect(); + for part in parts.iter().rev() { + let t = part.trim(); + if t.contains("autocorrected") { + return extract_leading_number(t); + } + } + 0 +} + +/// Compact Ruby file path by finding the nearest Rails convention directory +/// and stripping the absolute path prefix. +fn compact_ruby_path(path: &str) -> String { + let path = path.replace('\\', "/"); + + for prefix in &[ + "app/models/", + "app/controllers/", + "app/views/", + "app/helpers/", + "app/services/", + "app/jobs/", + "app/mailers/", + "lib/", + "spec/", + "test/", + "config/", + ] { + if let Some(pos) = path.find(prefix) { + return path[pos..].to_string(); + } + } + + // Generic: strip up to last known directory marker + if let Some(pos) = path.rfind("/app/") { + return path[pos + 1..].to_string(); + } + if let Some(pos) = path.rfind('/') { + return path[pos + 1..].to_string(); + } + path +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::utils::count_tokens; + + fn no_offenses_json() -> &'static str { + r#"{ + "metadata": {"rubocop_version": "1.60.0"}, + "files": [], + "summary": { + "offense_count": 0, + "target_file_count": 0, + "inspected_file_count": 15 + } + }"# + } + + fn with_offenses_json() -> &'static str { + r#"{ + "metadata": {"rubocop_version": "1.60.0"}, + "files": [ + { + "path": "app/models/user.rb", + "offenses": [ + { + "severity": "convention", + "message": "Trailing whitespace detected.", + "cop_name": "Layout/TrailingWhitespace", + "correctable": true, + "location": {"start_line": 10, "start_column": 5, "last_line": 10, "last_column": 8, "length": 3, "line": 10, "column": 5} + }, + { + "severity": "convention", + "message": "Missing frozen string literal comment.", + "cop_name": "Style/FrozenStringLiteralComment", + "correctable": true, + "location": {"start_line": 1, "start_column": 1, "last_line": 1, "last_column": 1, "length": 1, "line": 1, "column": 1} + }, + { + "severity": "warning", + "message": "Useless assignment to variable - `x`.", + "cop_name": "Lint/UselessAssignment", + "correctable": false, + "location": {"start_line": 25, "start_column": 5, "last_line": 25, "last_column": 6, "length": 1, "line": 25, "column": 5} + } + ] + }, + { + "path": "app/controllers/users_controller.rb", + "offenses": [ + { + "severity": "convention", + "message": "Trailing whitespace detected.", + "cop_name": "Layout/TrailingWhitespace", + "correctable": true, + "location": {"start_line": 5, "start_column": 20, "last_line": 5, "last_column": 22, "length": 2, "line": 5, "column": 20} + }, + { + "severity": "error", + "message": "Syntax error, unexpected end-of-input.", + "cop_name": "Lint/Syntax", + "correctable": false, + "location": {"start_line": 30, "start_column": 1, "last_line": 30, "last_column": 1, "length": 1, "line": 30, "column": 1} + } + ] + } + ], + "summary": { + "offense_count": 5, + "target_file_count": 2, + "inspected_file_count": 20 + } + }"# + } + + #[test] + fn test_filter_rubocop_no_offenses() { + let result = filter_rubocop_json(no_offenses_json()); + assert_eq!(result, "ok ✓ rubocop (15 files)"); + } + + #[test] + fn test_filter_rubocop_with_offenses_per_file() { + let result = filter_rubocop_json(with_offenses_json()); + // Should show per-file offenses + assert!(result.contains("5 offenses (20 files)")); + // controllers file has error severity, should appear first + assert!(result.contains("app/controllers/users_controller.rb")); + assert!(result.contains("app/models/user.rb")); + // Per-file offense format: :line CopName — message + assert!(result.contains(":30 Lint/Syntax — Syntax error")); + assert!(result.contains(":10 Layout/TrailingWhitespace — Trailing whitespace")); + assert!(result.contains(":25 Lint/UselessAssignment — Useless assignment")); + } + + #[test] + fn test_filter_rubocop_severity_ordering() { + let result = filter_rubocop_json(with_offenses_json()); + // File with error should come before file with only convention/warning + let ctrl_pos = result.find("users_controller.rb").unwrap(); + let model_pos = result.find("app/models/user.rb").unwrap(); + assert!( + ctrl_pos < model_pos, + "Error-file should appear before convention-file" + ); + + // Within users_controller.rb, error should come before convention + let error_pos = result.find(":30 Lint/Syntax").unwrap(); + let conv_pos = result.find(":5 Layout/TrailingWhitespace").unwrap(); + assert!( + error_pos < conv_pos, + "Error offense should appear before convention" + ); + } + + #[test] + fn test_filter_rubocop_within_file_line_ordering() { + let result = filter_rubocop_json(with_offenses_json()); + // Within user.rb, warning (line 25) should come before conventions (line 1, 10) + let warning_pos = result.find(":25 Lint/UselessAssignment").unwrap(); + let conv1_pos = result.find(":1 Style/FrozenStringLiteralComment").unwrap(); + assert!( + warning_pos < conv1_pos, + "Warning should come before convention within same file" + ); + } + + #[test] + fn test_filter_rubocop_correctable_hint() { + let result = filter_rubocop_json(with_offenses_json()); + assert!(result.contains("3 correctable")); + assert!(result.contains("rubocop -A")); + } + + #[test] + fn test_filter_rubocop_text_fallback() { + let text = r#"Inspecting 10 files +.......... + +10 files inspected, no offenses detected"#; + let result = filter_rubocop_text(text); + assert_eq!(result, "ok ✓ rubocop (10 files)"); + } + + #[test] + fn test_filter_rubocop_text_autocorrect() { + let text = r#"Inspecting 15 files +...C..CC....... + +15 files inspected, 3 offenses detected, 3 offenses autocorrected"#; + let result = filter_rubocop_text(text); + assert_eq!(result, "ok ✓ rubocop -A (15 files, 3 autocorrected)"); + } + + #[test] + fn test_filter_rubocop_empty_output() { + let result = filter_rubocop_json(""); + assert_eq!(result, "RuboCop: No output"); + } + + #[test] + fn test_filter_rubocop_invalid_json_falls_back() { + let garbage = "some ruby warning\n{broken json"; + let result = filter_rubocop_json(garbage); + assert!(!result.is_empty(), "should not panic on invalid JSON"); + } + + #[test] + fn test_compact_ruby_path() { + assert_eq!( + compact_ruby_path("/home/user/project/app/models/user.rb"), + "app/models/user.rb" + ); + assert_eq!( + compact_ruby_path("app/controllers/users_controller.rb"), + "app/controllers/users_controller.rb" + ); + assert_eq!( + compact_ruby_path("/project/spec/models/user_spec.rb"), + "spec/models/user_spec.rb" + ); + assert_eq!( + compact_ruby_path("lib/tasks/deploy.rake"), + "lib/tasks/deploy.rake" + ); + } + + #[test] + fn test_filter_rubocop_caps_offenses_per_file() { + // File with 7 offenses should show 5 + overflow + let json = r#"{ + "metadata": {"rubocop_version": "1.60.0"}, + "files": [ + { + "path": "app/models/big.rb", + "offenses": [ + {"severity": "convention", "message": "msg1", "cop_name": "Cop/A", "correctable": false, "location": {"start_line": 1, "start_column": 1}}, + {"severity": "convention", "message": "msg2", "cop_name": "Cop/B", "correctable": false, "location": {"start_line": 2, "start_column": 1}}, + {"severity": "convention", "message": "msg3", "cop_name": "Cop/C", "correctable": false, "location": {"start_line": 3, "start_column": 1}}, + {"severity": "convention", "message": "msg4", "cop_name": "Cop/D", "correctable": false, "location": {"start_line": 4, "start_column": 1}}, + {"severity": "convention", "message": "msg5", "cop_name": "Cop/E", "correctable": false, "location": {"start_line": 5, "start_column": 1}}, + {"severity": "convention", "message": "msg6", "cop_name": "Cop/F", "correctable": false, "location": {"start_line": 6, "start_column": 1}}, + {"severity": "convention", "message": "msg7", "cop_name": "Cop/G", "correctable": false, "location": {"start_line": 7, "start_column": 1}} + ] + } + ], + "summary": {"offense_count": 7, "target_file_count": 1, "inspected_file_count": 5} + }"#; + let result = filter_rubocop_json(json); + assert!(result.contains(":5 Cop/E"), "should show 5th offense"); + assert!(!result.contains(":6 Cop/F"), "should not show 6th inline"); + assert!(result.contains("+2 more"), "should show overflow"); + } + + #[test] + fn test_filter_rubocop_text_bundler_error() { + let text = "Bundler::GemNotFound: Could not find gem 'rubocop' in any sources."; + let result = filter_rubocop_text(text); + assert!( + result.starts_with("RuboCop error:"), + "should detect Bundler error: {}", + result + ); + assert!(result.contains("GemNotFound")); + } + + #[test] + fn test_filter_rubocop_text_load_error() { + let text = + "/usr/lib/ruby/3.2.0/rubygems.rb:250: cannot load such file -- rubocop (LoadError)"; + let result = filter_rubocop_text(text); + assert!( + result.starts_with("RuboCop error:"), + "should detect load error: {}", + result + ); + } + + #[test] + fn test_filter_rubocop_text_with_offenses() { + let text = r#"Inspecting 5 files +..C.. + +5 files inspected, 1 offense detected"#; + let result = filter_rubocop_text(text); + assert_eq!(result, "RuboCop: 5 files inspected, 1 offense detected"); + } + + #[test] + fn test_severity_rank() { + assert!(severity_rank("error") < severity_rank("warning")); + assert!(severity_rank("warning") < severity_rank("convention")); + assert!(severity_rank("fatal") < severity_rank("warning")); + } + + #[test] + fn test_token_savings() { + let input = with_offenses_json(); + let output = filter_rubocop_json(input); + + let input_tokens = count_tokens(input); + let output_tokens = count_tokens(&output); + let savings = 100.0 - (output_tokens as f64 / input_tokens as f64 * 100.0); + + assert!( + savings >= 60.0, + "RuboCop: expected ≥60% savings, got {:.1}% (in={}, out={})", + savings, + input_tokens, + output_tokens + ); + } + + // ── ANSI handling test ────────────────────────────────────────────────── + + #[test] + fn test_filter_rubocop_json_with_ansi_prefix() { + // ANSI codes before JSON should trigger fallback, not panic + let input = "\x1b[33mWarning: something\x1b[0m\n{\"broken\": true}"; + let result = filter_rubocop_json(input); + assert!(!result.is_empty(), "should not panic on ANSI-prefixed JSON"); + } + + // ── 10-file cap test (Issue 12) ───────────────────────────────────────── + + #[test] + fn test_filter_rubocop_caps_at_ten_files() { + // Build JSON with 12 files, each having 1 offense + let mut files_json = Vec::new(); + for i in 1..=12 { + files_json.push(format!( + r#"{{"path": "app/models/model_{}.rb", "offenses": [{{"severity": "convention", "message": "msg{}", "cop_name": "Cop/X{}", "correctable": false, "location": {{"start_line": 1, "start_column": 1}}}}]}}"#, + i, i, i + )); + } + let json = format!( + r#"{{"metadata": {{"rubocop_version": "1.60.0"}}, "files": [{}], "summary": {{"offense_count": 12, "target_file_count": 12, "inspected_file_count": 12}}}}"#, + files_json.join(",") + ); + let result = filter_rubocop_json(&json); + assert!( + result.contains("+2 more files"), + "should show +2 more files overflow: {}", + result + ); + } +} diff --git a/src/toml_filter.rs b/src/toml_filter.rs index 69db33bf..0f571626 100644 --- a/src/toml_filter.rs +++ b/src/toml_filter.rs @@ -1610,8 +1610,8 @@ match_command = "^make\\b" let filters = make_filters(BUILTIN_TOML); assert_eq!( filters.len(), - 57, - "Expected exactly 57 built-in filters, got {}. \ + 58, + "Expected exactly 58 built-in filters, got {}. \ Update this count when adding/removing filters in src/filters/.", filters.len() ); @@ -1668,11 +1668,11 @@ expected = "output line 1\noutput line 2" let combined = format!("{}\n\n{}", BUILTIN_TOML, new_filter); let filters = make_filters(&combined); - // All 57 existing filters still present + 1 new = 58 + // All 58 existing filters still present + 1 new = 59 assert_eq!( filters.len(), - 58, - "Expected 58 filters after concat (57 built-in + 1 new)" + 59, + "Expected 59 filters after concat (58 built-in + 1 new)" ); // New filter is discoverable diff --git a/src/utils.rs b/src/utils.rs index ff84961c..c1882fa8 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -207,6 +207,58 @@ pub fn ok_confirmation(action: &str, detail: &str) -> String { } } +/// Extract exit code from a process output. Returns the actual exit code, or +/// `128 + signal` per Unix convention when terminated by a signal (no exit code +/// available). Falls back to 1 on non-Unix platforms. +pub fn exit_code_from_output(output: &std::process::Output, label: &str) -> i32 { + match output.status.code() { + Some(code) => code, + None => { + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt; + if let Some(sig) = output.status.signal() { + eprintln!("[rtk] {}: process terminated by signal {}", label, sig); + return 128 + sig; + } + } + eprintln!("[rtk] {}: process terminated by signal", label); + 1 + } + } +} + +/// Return the last `n` lines of output with a label, for use as a fallback +/// when filter parsing fails. Logs a diagnostic to stderr. +pub fn fallback_tail(output: &str, label: &str, n: usize) -> String { + eprintln!( + "[rtk] {}: output format not recognized, showing last {} lines", + label, n + ); + let lines: Vec<&str> = output.lines().collect(); + let start = lines.len().saturating_sub(n); + lines[start..].join("\n") +} + +/// Build a Command for Ruby tools, auto-detecting bundle exec. +/// Uses `bundle exec ` when a Gemfile exists (transitive deps like rake +/// won't appear in the Gemfile but still need bundler for version isolation). +pub fn ruby_exec(tool: &str) -> Command { + if std::path::Path::new("Gemfile").exists() { + let mut c = Command::new("bundle"); + c.arg("exec").arg(tool); + return c; + } + Command::new(tool) +} + +/// Count whitespace-delimited tokens in text. Used by filter tests to verify +/// token savings claims. +#[cfg(test)] +pub fn count_tokens(text: &str) -> usize { + text.split_whitespace().count() +} + /// Detect the package manager used in the current directory. /// Returns "pnpm", "yarn", or "npm" based on lockfile presence. /// diff --git a/tests/fixtures/golangci_v2_json.txt b/tests/fixtures/golangci_v2_json.txt new file mode 100644 index 00000000..959b27f4 --- /dev/null +++ b/tests/fixtures/golangci_v2_json.txt @@ -0,0 +1,144 @@ +{ + "Issues": [ + { + "FromLinter": "errcheck", + "Text": "Error return value of `foo` is not checked", + "Severity": "error", + "SourceLines": [ + " if err := foo(); err != nil {", + " return err", + " }" + ], + "Pos": { + "Filename": "pkg/handler/server.go", + "Line": 42, + "Column": 5, + "Offset": 1024 + }, + "Replacement": null, + "ExpectNoLint": false, + "ExpectedNoLintLinter": "" + }, + { + "FromLinter": "errcheck", + "Text": "Error return value of `bar` is not checked", + "Severity": "error", + "SourceLines": [ + " bar()", + " return nil", + "}" + ], + "Pos": { + "Filename": "pkg/handler/server.go", + "Line": 55, + "Column": 2, + "Offset": 2048 + }, + "Replacement": null, + "ExpectNoLint": false, + "ExpectedNoLintLinter": "" + }, + { + "FromLinter": "gosimple", + "Text": "S1003: should replace strings.Index with strings.Contains", + "Severity": "warning", + "SourceLines": [ + " if strings.Index(s, sub) >= 0 {", + " return true", + " }" + ], + "Pos": { + "Filename": "pkg/utils/strings.go", + "Line": 15, + "Column": 2, + "Offset": 512 + }, + "Replacement": null, + "ExpectNoLint": false, + "ExpectedNoLintLinter": "" + }, + { + "FromLinter": "govet", + "Text": "printf: Sprintf format %s has arg of wrong type int", + "Severity": "error", + "SourceLines": [ + " fmt.Sprintf(\"%s\", 42)" + ], + "Pos": { + "Filename": "cmd/main/main.go", + "Line": 10, + "Column": 3, + "Offset": 256 + }, + "Replacement": null, + "ExpectNoLint": false, + "ExpectedNoLintLinter": "" + }, + { + "FromLinter": "unused", + "Text": "func `unusedHelper` is unused", + "Severity": "warning", + "SourceLines": [ + "func unusedHelper() {", + " // implementation", + "}" + ], + "Pos": { + "Filename": "internal/helpers.go", + "Line": 100, + "Column": 1, + "Offset": 4096 + }, + "Replacement": null, + "ExpectNoLint": false, + "ExpectedNoLintLinter": "" + }, + { + "FromLinter": "errcheck", + "Text": "Error return value of `close` is not checked", + "Severity": "error", + "SourceLines": [ + " defer file.Close()" + ], + "Pos": { + "Filename": "pkg/handler/server.go", + "Line": 120, + "Column": 10, + "Offset": 3072 + }, + "Replacement": null, + "ExpectNoLint": false, + "ExpectedNoLintLinter": "" + }, + { + "FromLinter": "gosimple", + "Text": "S1005: should omit nil check", + "Severity": "warning", + "SourceLines": [ + " if m != nil {", + " for k, v := range m {", + " process(k, v)", + " }", + " }" + ], + "Pos": { + "Filename": "pkg/utils/strings.go", + "Line": 45, + "Column": 1, + "Offset": 1536 + }, + "Replacement": null, + "ExpectNoLint": false, + "ExpectedNoLintLinter": "" + } + ], + "Report": { + "Warnings": [], + "Linters": [ + {"Name": "errcheck", "Enabled": true, "EnabledByDefault": true}, + {"Name": "gosimple", "Enabled": true, "EnabledByDefault": true}, + {"Name": "govet", "Enabled": true, "EnabledByDefault": true}, + {"Name": "unused", "Enabled": true, "EnabledByDefault": true} + ] + } +}