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:
@@ -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
|
||||
|
||||
```
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
|
||||
Executable
+463
@@ -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,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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
Reference in New Issue
Block a user