feat(ruby): add Ruby on Rails support (rspec, rubocop, rake, bundle) (#724)

* feat(ruby): add Ruby on Rails support (rspec, rubocop, rake, bundle)

Unifies 5 competing PRs (#198, #292, #379, #534, #643) into a single
coherent implementation.

New commands:
- rtk rspec: JSON parsing with text fallback (60%+ savings)
- rtk rubocop: JSON parsing, group by cop/severity (60%+ savings)
- rtk rake test: Minitest state machine parser (85-90% savings)
- rtk bundle install: TOML filter, strip Using lines (90%+ savings)

Shared infrastructure: ruby_exec(), fallback_tail(),
exit_code_from_output(), count_tokens() in utils.rs.

Discover/rewrite rules for rspec, rubocop, rake, rails, bundle
including bundle exec and bin/ variants.

E2E smoke tests (scripts/test-ruby.sh) covering all 4 commands.
56 new unit tests + 4 inline TOML tests. All 1035 tests passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Navid EMAD <navid.emad@yespark.fr>

* fix(ruby): use TEST= env var for rake single-file test in smoke tests

Rails' `rake test` ignores positional file args; use `TEST=path` syntax.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Navid EMAD <navid.emad@yespark.fr>

* docs(ruby): add Ruby module architecture and update attribution

Integrate ARCHITECTURE.md Ruby Module Architecture section and CLAUDE.md
module table/fork-features from PR #643. Update PR description attribution.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Navid EMAD <navid.emad@yespark.fr>

* chore: remove PULL_REQUEST_DESCRIPTION.md from repo

PR description lives on GitHub, no need to track in the codebase.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Navid EMAD <navid.emad@yespark.fr>

---------

Signed-off-by: Navid EMAD <navid.emad@yespark.fr>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Navid EMAD
2026-03-19 15:04:59 +01:00
committed by GitHub
parent 5e1fc20cb0
commit 15bc0f8d6e
14 changed files with 2903 additions and 9 deletions
+36
View File
@@ -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
```
+11
View File
@@ -5,6 +5,17 @@ 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]
### 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
## [0.30.1](https://github.com/rtk-ai/rtk/compare/v0.30.0...v0.30.1) (2026-03-18)
+13 -1
View File
@@ -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)
+8
View File
@@ -171,6 +171,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 +186,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 +194,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)
```
@@ -351,6 +355,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` |
+25 -3
View File
@@ -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"
+463
View File
@@ -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("<p>hello</p>")
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"
+44
View File
@@ -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",
+61
View File
@@ -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 = ""
+39
View File
@@ -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;
@@ -641,6 +644,27 @@ enum Commands {
args: Vec<String>,
},
/// 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<String>,
},
/// 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<String>,
},
/// 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<String>,
},
/// Pip package manager with compact output (auto-detects uv)
Pip {
/// Pip arguments (e.g., list, outdated, install)
@@ -1986,6 +2010,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 +2281,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 { .. }
+441
View File
@@ -0,0 +1,441 @@
//! 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};
pub fn run(args: &[String], verbose: u8) -> Result<()> {
let timer = tracking::TimedExecution::start();
let mut cmd = ruby_exec("rake");
for arg in args {
cmd.arg(arg);
}
if verbose > 0 {
eprintln!(
"Running: {} {}",
cmd.get_program().to_string_lossy(),
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<String> = Vec::new();
let mut current_failure: Vec<String> = 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::<usize>() {
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"));
}
}
+1046
View File
File diff suppressed because it is too large Load Diff
+659
View File
@@ -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<RubocopFile>,
summary: RubocopSummary,
}
#[derive(Deserialize)]
struct RubocopFile {
path: String,
offenses: Vec<RubocopOffense>,
}
#[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<RubocopOutput, _> = 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
);
}
}
+5 -5
View File
@@ -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
+52
View File
@@ -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 <tool>` 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.
///