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 @@
-
+
@@ -19,7 +19,7 @@
Install •
Troubleshooting •
Architecture •
- 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 @@
-
+
@@ -19,7 +19,7 @@
Instalar •
Solucion de problemas •
Arquitectura •
- 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 @@
-
+
@@ -19,7 +19,7 @@
Installer •
Depannage •
Architecture •
- 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 @@
-
+
@@ -19,7 +19,7 @@
インストール •
トラブルシューティング •
アーキテクチャ •
- Discord
+ Discord
@@ -152,7 +152,7 @@ rtk discover # 見逃した節約機会を発見
コントリビューション歓迎 で 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 @@
-
+
@@ -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 @@
-
+
@@ -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}
+ ]
+ }
+}