From 4c22082e10e02a0adaaf25d93b571949d9c94ad8 Mon Sep 17 00:00:00 2001 From: Navid EMAD Date: Sun, 1 Mar 2026 01:51:51 +0100 Subject: [PATCH] feat: add Ruby on Rails CLI support (rspec, rubocop, bundle, rails) Add 4 new command modules for the Ruby on Rails ecosystem with shared infrastructure, discover registry integration, and hook rewrites. 96+ unit tests, 527 total passing. New commands: - rtk rspec: JSON parsing with text fallback (60%+ / 30%+ savings) - rtk rubocop: JSON parsing, group by cop/severity (60%+ savings) - rtk bundle list/outdated/install/update: text parsing (10-30%) - rtk rails test/routes/db:migrate/status/rollback/generate (40-50%+) Shared infrastructure: - ruby_exec() auto-detects bundle exec when Gemfile declares gem - fallback_tail() last-resort filter with diagnostic logging - count_tokens() shared test helper (deduplicated from 4 modules) - exit_code_from_output() returns 128+signal on Unix Hook rewrites cover rspec, rubocop, bundle, rails, rake, and bin/bundle exec variants via .claude/hooks/rtk-rewrite.sh. --- .claude/hooks/rtk-rewrite.sh | 48 ++ ARCHITECTURE.md | 21 +- CLAUDE.md | 16 +- README.md | 37 + scripts/test-all.sh | 32 +- scripts/test-rails.sh | 334 ++++++++ src/bundle_cmd.rs | 644 +++++++++++++++ src/discover/registry.rs | 263 +++++- src/go_cmd.rs | 3 +- src/main.rs | 114 +++ src/rails_cmd.rs | 1461 ++++++++++++++++++++++++++++++++++ src/rspec_cmd.rs | 1002 +++++++++++++++++++++++ src/rubocop_cmd.rs | 659 +++++++++++++++ src/utils.rs | 157 ++++ 14 files changed, 4784 insertions(+), 7 deletions(-) create mode 100755 scripts/test-rails.sh create mode 100644 src/bundle_cmd.rs create mode 100644 src/rails_cmd.rs create mode 100644 src/rspec_cmd.rs create mode 100644 src/rubocop_cmd.rs diff --git a/.claude/hooks/rtk-rewrite.sh b/.claude/hooks/rtk-rewrite.sh index ea728576..bff9bfdb 100755 --- a/.claude/hooks/rtk-rewrite.sh +++ b/.claude/hooks/rtk-rewrite.sh @@ -190,6 +190,54 @@ elif echo "$MATCH_CMD" | grep -qE '^mypy([[:space:]]|$)'; then elif echo "$MATCH_CMD" | grep -qE '^python[[:space:]]+-m[[:space:]]+mypy([[:space:]]|$)'; then REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^python -m mypy/rtk mypy/')" +# --- Ruby tooling --- +elif echo "$MATCH_CMD" | grep -qE '^bundle[[:space:]]+(list|outdated|install|update)([[:space:]]|$)'; then + REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^bundle /rtk bundle /')" +elif echo "$MATCH_CMD" | grep -qE '^bundle[[:space:]]+exec[[:space:]]+rubocop([[:space:]]|$)'; then + REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^bundle exec rubocop/rtk rubocop/')" +elif echo "$MATCH_CMD" | grep -qE '^rubocop([[:space:]]|$)'; then + REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^rubocop/rtk rubocop/')" +elif echo "$MATCH_CMD" | grep -qE '^bundle[[:space:]]+exec[[:space:]]+rspec([[:space:]]|$)'; then + REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^bundle exec rspec/rtk rspec/')" +elif echo "$MATCH_CMD" | grep -qE '^bin/rspec([[:space:]]|$)'; then + REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's|^bin/rspec|rtk rspec|')" +elif echo "$MATCH_CMD" | grep -qE '^rspec([[:space:]]|$)'; then + REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^rspec/rtk rspec/')" +elif echo "$MATCH_CMD" | grep -qE '^bundle[[:space:]]+exec[[:space:]]+rails[[:space:]]+spec([[:space:]]|$)'; then + REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^bundle exec rails spec/rtk rspec/')" +elif echo "$MATCH_CMD" | grep -qE '^(bundle[[:space:]]+exec[[:space:]]+)?rails[[:space:]]+test([[:space:]]|$)'; then + REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed -E 's/^(bundle exec )?rails test/rtk rails test/')" +elif echo "$MATCH_CMD" | grep -qE '^bin/rails[[:space:]]+test([[:space:]]|$)'; then + REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's|^bin/rails test|rtk rails test|')" +elif echo "$MATCH_CMD" | grep -qE '^(bundle[[:space:]]+exec[[:space:]]+)?rails[[:space:]]+routes([[:space:]]|$)'; then + REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed -E 's/^(bundle exec )?rails routes/rtk rails routes/')" +elif echo "$MATCH_CMD" | grep -qE '^(bundle[[:space:]]+exec[[:space:]]+)?rails[[:space:]]+db:migrate:status([[:space:]]|$)'; then + REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed -E 's/^(bundle exec )?rails db:migrate:status/rtk rails db:migrate:status/')" +elif echo "$MATCH_CMD" | grep -qE '^(bundle[[:space:]]+exec[[:space:]]+)?rails[[:space:]]+db:migrate([[:space:]]|$)'; then + REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed -E 's/^(bundle exec )?rails db:migrate/rtk rails db:migrate/')" +elif echo "$MATCH_CMD" | grep -qE '^(bundle[[:space:]]+exec[[:space:]]+)?rails[[:space:]]+db:rollback([[:space:]]|$)'; then + REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed -E 's/^(bundle exec )?rails db:rollback/rtk rails db:rollback/')" +elif echo "$MATCH_CMD" | grep -qE '^(bundle[[:space:]]+exec[[:space:]]+)?rails[[:space:]]+(generate|g)([[:space:]]|$)'; then + REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed -E 's/^(bundle exec )?rails (generate|g)/rtk rails generate/')" +elif echo "$MATCH_CMD" | grep -qE '^bin/rails[[:space:]]+routes([[:space:]]|$)'; then + REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's|^bin/rails routes|rtk rails routes|')" +elif echo "$MATCH_CMD" | grep -qE '^bin/rails[[:space:]]+db:migrate:status([[:space:]]|$)'; then + REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's|^bin/rails db:migrate:status|rtk rails db:migrate:status|')" +elif echo "$MATCH_CMD" | grep -qE '^bin/rails[[:space:]]+db:migrate([[:space:]]|$)'; then + REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's|^bin/rails db:migrate|rtk rails db:migrate|')" +elif echo "$MATCH_CMD" | grep -qE '^bin/rails[[:space:]]+db:rollback([[:space:]]|$)'; then + REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's|^bin/rails db:rollback|rtk rails db:rollback|')" +elif echo "$MATCH_CMD" | grep -qE '^bin/rails[[:space:]]+(generate|g)([[:space:]]|$)'; then + REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed -E 's|^bin/rails (generate\|g)|rtk rails generate|')" +elif echo "$MATCH_CMD" | grep -qE '^rake[[:space:]]+routes([[:space:]]|$)'; then + REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^rake routes/rtk rails routes/')" +elif echo "$MATCH_CMD" | grep -qE '^rake[[:space:]]+db:migrate:status([[:space:]]|$)'; then + REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^rake db:migrate:status/rtk rails db:migrate:status/')" +elif echo "$MATCH_CMD" | grep -qE '^rake[[:space:]]+db:migrate([[:space:]]|$)'; then + REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^rake db:migrate/rtk rails db:migrate/')" +elif echo "$MATCH_CMD" | grep -qE '^rake[[:space:]]+db:rollback([[:space:]]|$)'; then + REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^rake db:rollback/rtk rails db:rollback/')" + # --- Go tooling --- elif echo "$MATCH_CMD" | grep -qE '^go[[:space:]]+test([[:space:]]|$)'; then REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^go test/rtk go test/')" diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 7d63d3c8..2356ad48 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -272,6 +272,11 @@ 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 rspec_cmd.rs rspec 60%+ ✓ + rubocop_cmd.rs rubocop 60%+ ✓ + bundle_cmd.rs bundle list/outdated/install/update 10-30% ✓ + rails_cmd.rs rails test/routes/db 40-50%+ ✓ + NETWORK wget_cmd.rs wget 85-95% ✓ DEPENDENCIES deps.rs deps 80-90% ✓ @@ -288,16 +293,17 @@ SHARED utils.rs Helpers N/A ✓ tee.rs Full output recovery N/A ✓ ``` -**Total: 50 modules** (32 command modules + 18 infrastructure modules) +**Total: 54 modules** (36 command modules + 18 infrastructure modules) ### Module Count Breakdown -- **Command Modules**: 31 (directly exposed to users) +- **Command Modules**: 36 (directly exposed to users) - **Infrastructure Modules**: 18 (utils, filter, tracking, tee, config, init, gain, etc.) - **Git Commands**: 7 operations (status, diff, log, add, commit, push, branch/checkout) - **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**: 4 modules (rspec, rubocop, bundle, rails) --- @@ -463,6 +469,17 @@ Commands::Pip { args } Build { args }, └─ pip_cmd.rs ├─ go_cmd.rs (sub-enum router) └─ golangci_cmd.rs +Commands::Rspec { args } Commands::Rails { command } +Commands::Rubocop { args } │ +Commands::Bundle { args } ├─ rails_cmd.rs (sub-enum router) +│ │ ├─ Test { args } +├─ rspec_cmd.rs │ ├─ Routes { args } +├─ rubocop_cmd.rs │ ├─ DbMigrate { args } +└─ bundle_cmd.rs (subcommand router) │ ├─ DbMigrateStatus { args } + │ ├─ DbRollback { args } + │ ├─ Generate { args } + │ └─ Other(Vec) + Mirrors: lint, prettier Mirrors: git, cargo ``` diff --git a/CLAUDE.md b/CLAUDE.md index e06e73db..15e68129 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -230,8 +230,12 @@ 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) | +| rspec_cmd.rs | RSpec test runner | JSON parsing, failures only (60%+ JSON, 30%+ text fallback) | +| rubocop_cmd.rs | RuboCop linter | JSON parsing, group by cop name (60%+ reduction) | +| bundle_cmd.rs | Bundler package manager | Text parsing for list/outdated/install/update (10-30% reduction) | +| rails_cmd.rs | Rails commands | Sub-enum: test/routes/db:migrate (40-50%+ 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 +396,16 @@ 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-02-28) +- **Ruby Commands**: 4 modules covering the full Rails development workflow + - `rtk rspec`: RSpec test runner with JSON parsing (`--format json`), text fallback (60%+ JSON, 30%+ text fallback) + - `rtk rubocop`: RuboCop linter with JSON parsing, group by cop name/severity (60%+ reduction) + - `rtk bundle list/outdated/install/update`: Bundler package manager with subcommand dispatch (10-30% reduction) + - `rtk rails test/routes/db:migrate/db:migrate:status/db:rollback/generate`: Rails sub-enum with minitest parser, route grouping, migration summary (40-50%+ reduction) +- **Shared Infrastructure**: `ruby_exec()` in utils.rs auto-detects `bundle exec` when Gemfile exists +- **Architecture**: Standalone commands (rspec, rubocop, bundle) + sub-enum (rails, mirrors go_cmd.rs pattern) +- **Hook Integration**: Rewrites `rspec`, `rubocop`, `bundle list/outdated/install/update`, `rails test/routes/db:migrate/db:migrate:status/db:rollback/generate`, `bundle exec`/`bin/` variants, and `rake routes/db:migrate/db:migrate:status/db:rollback` variants + ## Testing Strategy ### TDD Workflow (mandatory) diff --git a/README.md b/README.md index b6537eab..15363aec 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,12 @@ rtk pytest # Python tests (failures only, 90% reduction) rtk pip list # Python packages (auto-detect uv, 70% reduction) rtk go test # Go tests (NDJSON, 90% reduction) rtk golangci-lint run # Go linting (JSON, 85% reduction) +rtk rspec # RSpec tests (JSON, 60%+ reduction) +rtk rubocop # RuboCop linting (JSON, 60%+ reduction) +rtk bundle list # Bundler packages (compact format) +rtk rails test # Rails minitest (failures only, 50%+ reduction) +rtk rails routes # Routes grouped by controller (50%+ reduction) +rtk rails db:migrate # Migration summary (40%+ reduction) ``` ### Data & Analytics @@ -284,6 +290,32 @@ rtk go vet # Vet issues (75% reduction) rtk golangci-lint run # JSON grouped by rule (85% reduction) ``` +### Ruby on Rails Stack +```bash +# Testing +rtk rspec # RSpec tests (JSON parser, 60%+ reduction) +rtk rspec spec/models/ # Run specific directory +rtk rails test # Minitest (state machine parser, 50%+ reduction) +rtk rails test test/models/ # Run specific directory + +# Linting +rtk rubocop # RuboCop (JSON, group by cop, 60%+ reduction) +rtk rubocop -A # Auto-correct with summary + +# Package Management +rtk bundle list # Gem list with counts (10%+ reduction) +rtk bundle outdated # Outdated gems with version transitions (30%+ reduction) +rtk bundle install # Install summary (new/updated gems only) +rtk bundle update # Update summary (same filter as install) + +# Rails +rtk rails routes # Routes grouped by controller (50%+ reduction) +rtk rails db:migrate # Migration summary (40%+ reduction) +rtk rails db:migrate:status # Pending migration status +rtk rails db:rollback # Rollback summary +rtk rails generate model User # Generator summary (created files) +``` + ## Examples ### Standard vs rtk @@ -629,6 +661,11 @@ The hook is included in this repository at `.claude/hooks/rtk-rewrite.sh`. To us | `kubectl get/logs` | `rtk kubectl ...` | | `curl` | `rtk curl` | | `pnpm list/ls/outdated` | `rtk pnpm ...` | +| `rspec/bundle exec rspec/bin/rspec` | `rtk rspec ...` | +| `rubocop/bundle exec rubocop` | `rtk rubocop ...` | +| `bundle list/outdated/install/update` | `rtk bundle ...` | +| `rails test/routes/db:migrate/...` | `rtk rails ...` | +| `rake routes/db:migrate` | `rtk rails ...` | Commands already using `rtk`, heredocs (`<<`), and unrecognized commands pass through unchanged. diff --git a/scripts/test-all.sh b/scripts/test-all.sh index 74203f49..2e8aa1e3 100755 --- a/scripts/test-all.sh +++ b/scripts/test-all.sh @@ -413,7 +413,37 @@ else skip "golangci-lint not installed" fi -# ── 29. Global flags ──────────────────────────────── +# ── 29. Ruby (conditional) ────────────────────────── + +section "Ruby (conditional)" + +if command -v rspec &>/dev/null; then + assert_help "rtk rspec" rtk rspec --help +else + skip "rspec not installed" +fi + +if command -v rubocop &>/dev/null; then + assert_help "rtk rubocop" rtk rubocop --help +else + skip "rubocop not installed" +fi + +if command -v bundle &>/dev/null; then + assert_help "rtk bundle" rtk bundle --help +else + skip "bundler not installed" +fi + +if command -v rails &>/dev/null; then + assert_help "rtk rails" rtk rails --help + assert_help "rtk rails test" rtk rails test -h + assert_help "rtk rails routes" rtk rails routes -h +else + skip "rails not installed" +fi + +# ── 30. Global flags ──────────────────────────────── section "Global flags" diff --git a/scripts/test-rails.sh b/scripts/test-rails.sh new file mode 100755 index 00000000..20ff982b --- /dev/null +++ b/scripts/test-rails.sh @@ -0,0 +1,334 @@ +#!/usr/bin/env bash +# +# RTK Smoke Tests — Ruby on Rails (temp app) +# Creates a minimal Rails app, exercises all RTK Ruby/Rails filters, then cleans up. +# Usage: bash scripts/test-rails.sh +# +# Prerequisites: rtk, 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" +} + +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 on Rails${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-rails-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 minitest tests in test/) +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 minitest test +printf " → creating failing minitest test ...\n" +cat > test/models/post_fail_test.rb <<'FAILTEST' +require "test_helper" + +class PostFailTest < ActiveSupport::TestCase + test "this test intentionally fails" do + post = Post.new(title: nil, body: nil, published: nil) + assert_equal "Expected Title", post.title, "Title should match but won't" + end +end +FAILTEST + +# 9. 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 + +printf "\n${BOLD}Setup complete. Running tests...${NC}\n" + +# ══════════════════════════════════════════════════════ +# Test sections +# ══════════════════════════════════════════════════════ + +# ── 1. rails generate ─────────────────────────────── + +section "Rails generate" + +assert_output "rtk rails generate model Comment" \ + "files" \ + rtk rails generate model Comment post:references body:text + +# Migrate the new model for later tests +rails db:migrate --quiet 2>&1 || true + +# ── 2. rails db:migrate ───────────────────────────── + +section "Rails db:migrate" + +assert_output "rtk rails db:migrate (no-op)" \ + "db:migrate\|migrate\|already" \ + rtk rails db:migrate + +# ── 3. rails db:migrate:status ────────────────────── + +section "Rails db:migrate:status" + +assert_output "rtk rails db:migrate:status" \ + "migration" \ + rtk rails db:migrate:status + +# ── 4. rails db:rollback ──────────────────────────── + +section "Rails db:rollback" + +assert_output "rtk rails db:rollback" \ + "db:migrate\|rollback\|revert" \ + rtk rails db:rollback + +# Re-migrate so later tests have all tables +rails db:migrate --quiet 2>&1 || true + +# ── 5. rails test ─────────────────────────────────── + +section "Rails test (Minitest)" + +assert_output "rtk rails test (with failure)" \ + "failed\|failure\|FAIL" \ + rtk rails test + +# ── 6. rails routes ───────────────────────────────── + +section "Rails routes" + +assert_output "rtk rails routes" \ + "Routes\|route" \ + rtk rails routes + +# ── 7. 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 + +# ── 8. 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/ + +# ── 9. bundle list ────────────────────────────────── + +section "Bundle" + +assert_output "rtk bundle list" \ + "gems\|Bundle" \ + rtk bundle list + +assert_output "rtk bundle outdated" \ + "Bundle\|outdated\|up to date\|Gem\|Current" \ + rtk bundle outdated + +assert_output "rtk bundle install (idempotent)" \ + "bundle install\|gems" \ + rtk bundle install + +assert_output "rtk bundle update" \ + "gems\|bundle" \ + rtk bundle update + +# ══════════════════════════════════════════════════════ +# Report +# ══════════════════════════════════════════════════════ + +printf "\n${BOLD}══════════════════════════════════════${NC}\n" +printf "${BOLD}Results: ${GREEN}%d passed${NC}, ${RED}%d failed${NC}, ${YELLOW}%d skipped${NC}\n" "$PASS" "$FAIL" "$SKIP" + +if [[ ${#FAILURES[@]} -gt 0 ]]; then + printf "\n${RED}Failures:${NC}\n" + for f in "${FAILURES[@]}"; do + printf " - %s\n" "$f" + done +fi + +printf "${BOLD}══════════════════════════════════════${NC}\n" + +exit "$FAIL" diff --git a/src/bundle_cmd.rs b/src/bundle_cmd.rs new file mode 100644 index 00000000..45f8444c --- /dev/null +++ b/src/bundle_cmd.rs @@ -0,0 +1,644 @@ +//! Bundler package manager filter. +//! +//! Handles `bundle list`, `outdated`, `install`, and `update` with text-based +//! parsing. Unrecognized subcommands pass through to bundler directly. + +use crate::tracking; +use crate::utils::exit_code_from_output; +use anyhow::{Context, Result}; +use lazy_static::lazy_static; +use regex::Regex; +use std::process::Command; + +lazy_static! { + static ref RE_INSTALLING: Regex = + Regex::new(r"^Installing\s+(\S+)\s+(\S+)(?:\s+\(was\s+(\S+)\))?").unwrap(); +} + +// ── Public entry point ─────────────────────────────────────────────────────── + +pub fn run(args: &[String], verbose: u8) -> Result<()> { + let timer = tracking::TimedExecution::start(); + + let subcommand = args.first().map(|s| s.as_str()).unwrap_or(""); + + let (raw, filtered, exit_code) = match subcommand { + "list" => run_filtered("list", &args[1..], verbose, filter_bundle_list)?, + "outdated" => run_filtered("outdated", &args[1..], verbose, filter_bundle_outdated)?, + "install" => run_filtered("install", &args[1..], verbose, filter_bundle_install)?, + "update" => run_filtered("update", &args[1..], verbose, filter_bundle_install)?, + _ => run_passthrough(args, verbose)?, + }; + + timer.track( + &format!("bundle {}", args.join(" ")), + &format!("rtk bundle {}", args.join(" ")), + &raw, + &filtered, + ); + + if exit_code != 0 { + std::process::exit(exit_code); + } + + Ok(()) +} + +// ── Subcommand execution ───────────────────────────────────────────────────── + +/// Execute a bundle subcommand, apply a filter to stdout, handle tee/exit code. +/// Returns (raw, filtered, exit_code) so the caller can track before exiting. +fn run_filtered( + subcommand: &str, + args: &[String], + verbose: u8, + filter: fn(&str) -> String, +) -> Result<(String, String, i32)> { + // bundle itself doesn't need ruby_exec (bundle exec bundle is redundant) + let mut cmd = Command::new("bundle"); + cmd.arg(subcommand).args(args); + + if verbose > 0 { + eprintln!("Running: bundle {} {}", subcommand, args.join(" ")); + } + + let output = cmd.output().with_context(|| { + format!( + "Failed to run bundle {}. Is bundler installed? Check: which bundle, ruby --version", + subcommand + ) + })?; + + 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, &format!("bundle {}", subcommand)); + + let filtered = if stdout.trim().is_empty() && !output.status.success() { + format!( + "Bundle {}: FAILED (no stdout, see stderr below)", + subcommand + ) + } else { + filter(&stdout) + }; + + if let Some(hint) = crate::tee::tee_and_hint(&raw, &format!("bundle-{}", subcommand), exit_code) + { + println!("{}\n{}", filtered, hint); + } else { + println!("{}", filtered); + } + + if !stderr.trim().is_empty() && (!output.status.success() || verbose > 0) { + eprintln!("{}", stderr.trim()); + } + + Ok((raw, filtered, exit_code)) +} + +fn run_passthrough(args: &[String], verbose: u8) -> Result<(String, String, i32)> { + let mut cmd = Command::new("bundle"); + cmd.args(args); + + if verbose > 0 { + eprintln!("Running: bundle {}", args.join(" ")); + } + + let output = cmd.output().with_context(|| { + format!( + "Failed to run bundle {}. Is bundler installed? Check: which bundle, ruby --version", + args.join(" ") + ) + })?; + + 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, &format!("bundle {}", args.join(" "))); + + print!("{}", stdout); + eprint!("{}", stderr); + + Ok((raw.clone(), raw, exit_code)) +} + +// ── Filters ────────────────────────────────────────────────────────────────── + +/// Filter `bundle list` output. +/// Input format: "Gems included by the bundle:\n * gem_name (version)\n ..." +fn filter_bundle_list(output: &str) -> String { + let output = &crate::utils::strip_ansi(output); + let mut gems: Vec<(&str, &str)> = Vec::new(); + + for line in output.lines() { + let trimmed = line.trim(); + // Parse lines like " * gem_name (1.2.3)" or " * gem_name (1.2.3 abc123)" + if let Some(rest) = trimmed.strip_prefix("* ") { + if let Some(paren_pos) = rest.find('(') { + let name = rest[..paren_pos].trim(); + let version = rest[paren_pos..].trim_matches(|c| c == '(' || c == ')'); + // Take only the version number (before any space for git hash) + let version = version.split_whitespace().next().unwrap_or(version); + gems.push((name, version)); + } + } + } + + if gems.is_empty() { + // Check if there's an error message (avoid matching gem names like "better_errors") + if output.contains("Could not ") + || output.contains("Bundler could not") + || output.contains("An error occurred") + { + return output.trim().to_string(); + } + return "Bundle: No gems found".to_string(); + } + + let mut result = format!("Bundle: {} gems\n", gems.len()); + result.push_str("═══════════════════════════════════════\n"); + + for (name, version) in gems.iter().take(30) { + result.push_str(&format!(" {} ({})\n", name, version)); + } + + if gems.len() > 30 { + result.push_str(&format!(" ... +{} more gems\n", gems.len() - 30)); + } + + result.trim().to_string() +} + +/// Filter `bundle outdated` output. +/// Input format: "Outdated gems included in the bundle:\n * gem (newest N, installed M, requested ~> X) in group Y\n ..." +fn filter_bundle_outdated(output: &str) -> String { + let mut outdated: Vec<(String, String, String)> = Vec::new(); // (name, installed, newest) + + for line in output.lines() { + let trimmed = line.trim(); + if let Some(rest) = trimmed.strip_prefix("* ") { + // Parse: "gem_name (newest 2.0, installed 1.5, requested ~> 1.0) in group default" + if let Some(paren_pos) = rest.find('(') { + let name = rest[..paren_pos].trim().to_string(); + let details = rest[paren_pos..].trim(); + + let newest = extract_version_field(details, "newest"); + let installed = extract_version_field(details, "installed"); + + outdated.push((name, installed, newest)); + } + } + } + + if outdated.is_empty() { + if output.contains("Bundle up to date") + || output.contains("no outdated") + || output.trim().is_empty() + { + return "✓ Bundle: All gems up to date".to_string(); + } + // Might be an error or different format + return crate::utils::fallback_tail(output, "bundle outdated", 3); + } + + let mut result = format!("Bundle outdated: {} gems\n", outdated.len()); + result.push_str("═══════════════════════════════════════\n"); + + for (i, (name, installed, newest)) in outdated.iter().take(20).enumerate() { + result.push_str(&format!( + "{}. {} ({} → {})\n", + i + 1, + name, + installed, + newest + )); + } + + if outdated.len() > 20 { + result.push_str(&format!("\n... +{} more gems\n", outdated.len() - 20)); + } + + result.push_str("\nRun `bundle update ` to update"); + + result.trim().to_string() +} + +/// Filter `bundle install` / `bundle update` output. +/// Detect "Installing X Y (was Z)" for updates, strip noise lines, keep post-install messages. +fn filter_bundle_install(output: &str) -> String { + let mut installed: Vec = Vec::new(); // "name version" + let mut updated: Vec = Vec::new(); // "name old → new" + let mut using_count = 0; + let mut summary_line = String::new(); + let mut post_install_msgs: Vec = Vec::new(); + + for line in output.lines() { + let trimmed = line.trim(); + + // Skip noise lines + if trimmed.starts_with("Fetching gem metadata") + || trimmed.starts_with("Resolving dependencies") + || trimmed.starts_with("Fetching source index") + { + continue; + } + + if trimmed.starts_with("Using ") { + using_count += 1; + } else if let Some(caps) = RE_INSTALLING.captures(trimmed) { + let name = caps.get(1).map_or("", |m| m.as_str()); + let version = caps.get(2).map_or("", |m| m.as_str()); + if let Some(was) = caps.get(3) { + updated.push(format!("{} {} → {}", name, was.as_str(), version)); + } else { + installed.push(format!("{} {}", name, version)); + } + } else if trimmed.starts_with("Bundle complete!") || trimmed.starts_with("Bundle updated!") + { + summary_line = trimmed.to_string(); + } else if trimmed.starts_with("Bundler could not") + || trimmed.starts_with("An error occurred") + || trimmed.starts_with("Could not find gem") + || trimmed.starts_with("There was an error") + || trimmed.starts_with("Your Ruby version is") + || trimmed.starts_with("Bundler::GemNotFound") + { + // On error, return the output from this error line onward + return output + .find(trimmed) + .map(|pos| output[pos..].trim().to_string()) + .unwrap_or_else(|| crate::utils::fallback_tail(output, "bundle install", 5)); + } else if !trimmed.is_empty() + && !trimmed.starts_with("Downloading") + && !trimmed.starts_with("Fetching ") + { + // Post-install messages from gems + post_install_msgs.push(trimmed.to_string()); + } + } + + let total_gems = using_count + installed.len() + updated.len(); + + // No changes: compact summary + if installed.is_empty() && updated.is_empty() { + if !summary_line.is_empty() || total_gems > 0 { + return format!("ok ✓ bundle install ({} gems)", total_gems); + } + // Fallback: return last few lines + return crate::utils::fallback_tail(output, "bundle install", 5); + } + + // With changes + let mut result = format!("ok ✓ bundle install ({} gems)\n", total_gems); + + if !installed.is_empty() { + for gem in installed.iter().take(20) { + result.push_str(&format!(" installed: {}\n", gem)); + } + if installed.len() > 20 { + result.push_str(&format!(" ... +{} more installed\n", installed.len() - 20)); + } + } + + if !updated.is_empty() { + for gem in updated.iter().take(20) { + result.push_str(&format!(" updated: {}\n", gem)); + } + if updated.len() > 20 { + result.push_str(&format!(" ... +{} more updated\n", updated.len() - 20)); + } + } + + // Keep post-install messages (can contain breaking change notices) + if !post_install_msgs.is_empty() { + let meaningful: Vec<&String> = post_install_msgs + .iter() + .filter(|m| !m.starts_with("*") || m.contains("NOTICE") || m.contains("WARNING")) + .take(5) + .collect(); + if !meaningful.is_empty() { + result.push('\n'); + for msg in meaningful { + result.push_str(&format!(" {}\n", msg)); + } + } + } + + result.trim().to_string() +} + +/// Extract a version field from bundle outdated details string. +/// e.g., extract_version_field("(newest 2.0, installed 1.5)", "newest") -> "2.0" +fn extract_version_field(details: &str, field: &str) -> String { + if let Some(pos) = details.find(field) { + let after = &details[pos + field.len()..]; + let after = after.trim_start(); + let version: String = after + .chars() + .take_while(|c| !c.is_whitespace() && *c != ',' && *c != ')') + .collect(); + if !version.is_empty() { + return version; + } + } + "unknown".to_string() +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::utils::count_tokens; + + #[test] + fn test_filter_bundle_list() { + let output = r#"Gems included by the bundle: + * actioncable (7.1.3) + * actionmailbox (7.1.3) + * actionmailer (7.1.3) + * actionpack (7.1.3) + * activerecord (7.1.3) + * devise (4.9.3) + * puma (6.4.2) + * rails (7.1.3) + * sidekiq (7.2.0) + * turbo-rails (2.0.4) +"#; + let result = filter_bundle_list(output); + assert!(result.contains("10 gems")); + assert!(result.contains("rails (7.1.3)")); + assert!(result.contains("devise (4.9.3)")); + } + + #[test] + fn test_filter_bundle_list_empty() { + let result = filter_bundle_list(""); + assert!(result.contains("No gems found")); + } + + #[test] + fn test_filter_bundle_list_with_git_hash() { + let output = r#"Gems included by the bundle: + * my_gem (1.2.3 abc1234) + * other_gem (2.0.0) +"#; + let result = filter_bundle_list(output); + assert!(result.contains("2 gems")); + assert!(result.contains("my_gem (1.2.3)")); + assert!(result.contains("other_gem (2.0.0)")); + } + + #[test] + fn test_filter_bundle_outdated_none() { + let result = filter_bundle_outdated("Bundle up to date!"); + assert!(result.contains("✓ Bundle")); + assert!(result.contains("up to date")); + } + + #[test] + fn test_filter_bundle_outdated_some() { + let output = r#"Outdated gems included in the bundle: + * faker (newest 3.3.1, installed 3.2.0, requested ~> 3.0) in group development, test + * devise (newest 4.9.4, installed 4.9.3, requested ~> 4.9) in group default + * puma (newest 6.5.0, installed 6.4.2) in group default +"#; + let result = filter_bundle_outdated(output); + assert!(result.contains("3 gems")); + assert!(result.contains("faker (3.2.0 → 3.3.1)")); + assert!(result.contains("devise (4.9.3 → 4.9.4)")); + assert!(result.contains("puma (6.4.2 → 6.5.0)")); + } + + #[test] + fn test_filter_bundle_install_all_cached() { + let output = r#"Using rake 13.1.0 +Using concurrent-ruby 1.2.3 +Using activesupport 7.1.3 +Using rails 7.1.3 +Bundle complete! 50 gems, 3 git sources +"#; + let result = filter_bundle_install(output); + assert!(result.contains("ok ✓ bundle install")); + assert!(result.contains("4 gems")); + assert!(!result.contains("Using rake")); + } + + #[test] + fn test_filter_bundle_install_new_gems() { + let output = r#"Using rake 13.1.0 +Using concurrent-ruby 1.2.3 +Installing faker 3.3.1 +Installing devise 4.9.4 +Bundle complete! 52 gems, 3 git sources +"#; + let result = filter_bundle_install(output); + assert!(result.contains("ok ✓ bundle install")); + assert!(result.contains("installed: faker 3.3.1")); + assert!(result.contains("installed: devise 4.9.4")); + assert!(!result.contains("Using rake")); + } + + #[test] + fn test_filter_bundle_install_with_updates() { + let output = r#"Fetching gem metadata from https://rubygems.org/......... +Resolving dependencies... +Using rake 13.2.1 +Using concurrent-ruby 1.3.5 +Installing sidekiq 8.1.1 (was 8.0.0) +Installing stripe 18.4.0 +Bundle complete! 142 gems, 387 total gems installed. +"#; + let result = filter_bundle_install(output); + assert!(result.contains("ok ✓ bundle install")); + assert!( + result.contains("updated: sidekiq 8.0.0 → 8.1.1"), + "should detect update pattern: {}", + result + ); + assert!(result.contains("installed: stripe 18.4.0")); + assert!(!result.contains("Fetching gem metadata")); + assert!(!result.contains("Resolving dependencies")); + } + + #[test] + fn test_filter_bundle_install_failure() { + let output = r#"Fetching gem metadata from https://rubygems.org/......... +Resolving dependencies... +Bundler could not find compatible versions for gem "activerecord": + In Gemfile: + rails (= 8.1.2) was resolved to 8.1.2, which depends on + activerecord (= 8.1.2) +"#; + let result = filter_bundle_install(output); + assert!(result.contains("Bundler could not find")); + assert!(result.contains("activerecord")); + } + + #[test] + fn test_extract_version_field() { + assert_eq!( + extract_version_field("(newest 3.3.1, installed 3.2.0)", "newest"), + "3.3.1" + ); + assert_eq!( + extract_version_field("(newest 3.3.1, installed 3.2.0)", "installed"), + "3.2.0" + ); + assert_eq!( + extract_version_field("(newest 6.5.0, installed 6.4.2)", "newest"), + "6.5.0" + ); + } + + #[test] + fn test_token_savings_list() { + let input = r#"Gems included by the bundle: + * actioncable (7.1.3) + * actionmailbox (7.1.3) + * actionmailer (7.1.3) + * actionpack (7.1.3) + * actiontext (7.1.3) + * actionview (7.1.3) + * activejob (7.1.3) + * activemodel (7.1.3) + * activerecord (7.1.3) + * activestorage (7.1.3) + * activesupport (7.1.3) + * bootsnap (1.18.3) + * builder (3.2.4) + * concurrent-ruby (1.2.3) + * devise (4.9.3) + * erubi (1.12.0) + * globalid (1.2.1) + * i18n (1.14.4) + * jbuilder (2.12.0) + * loofah (2.22.0) + * mail (2.8.1) + * marcel (1.0.4) + * method_source (1.0.0) + * minitest (5.22.3) + * msgpack (1.7.2) + * net-imap (0.4.10) + * net-pop (0.1.2) + * net-smtp (0.5.0) + * nio4r (2.7.0) + * nokogiri (1.16.3) + * puma (6.4.2) + * racc (1.7.3) + * rack (3.0.9) + * rack-session (2.0.0) + * rack-test (2.1.0) + * rackup (2.1.0) + * rails (7.1.3) + * rails-dom-testing (2.2.0) + * rails-html-sanitizer (1.6.0) + * railties (7.1.3) + * rake (13.1.0) + * rdoc (6.6.3) + * redis (5.1.0) + * reline (0.5.0) + * sidekiq (7.2.0) + * sprockets (4.2.1) + * sprockets-rails (3.4.2) + * stimulus-rails (1.3.3) + * thor (1.3.1) + * turbo-rails (2.0.4) + * tzinfo (2.0.6) + * web-console (4.2.1) + * websocket-driver (0.7.6) + * websocket-extensions (0.1.5) +"#; + let output = filter_bundle_list(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 >= 10.0, + "Bundle list: expected ≥10% savings, got {:.1}%", + savings + ); + } + + #[test] + fn test_token_savings_outdated() { + let input = r#"Outdated gems included in the bundle: + * faker (newest 3.3.1, installed 3.2.0, requested ~> 3.0) in group development, test + * devise (newest 4.9.4, installed 4.9.3, requested ~> 4.9) in group default + * puma (newest 6.5.0, installed 6.4.2) in group default + * sidekiq (newest 7.3.0, installed 7.2.0, requested ~> 7.0) in group default + * turbo-rails (newest 2.1.0, installed 2.0.4, requested ~> 2.0) in group default +"#; + let output = filter_bundle_outdated(input); + + let input_tokens = count_tokens(input); + let output_tokens = count_tokens(&output); + let savings = 100.0 - (output_tokens as f64 / input_tokens as f64 * 100.0); + + assert!( + savings >= 30.0, + "Bundle outdated: expected ≥30% savings, got {:.1}%", + savings + ); + } + + #[test] + fn test_token_savings_install() { + let input = r#"Fetching gem metadata from https://rubygems.org/......... +Resolving dependencies... +Using rake 13.2.1 +Using concurrent-ruby 1.3.5 +Using activesupport 7.1.3 +Using rails-dom-testing 2.2.0 +Using rails-html-sanitizer 1.6.0 +Using actionview 7.1.3 +Using actionpack 7.1.3 +Using activemodel 7.1.3 +Using activerecord 7.1.3 +Using devise 4.9.3 +Using puma 6.4.2 +Using sidekiq 7.2.0 +Using turbo-rails 2.0.4 +Installing faker 3.3.1 +Installing stripe 18.4.0 +Bundle complete! 52 gems, 387 total gems installed. +"#; + let output = filter_bundle_install(input); + + let input_tokens = count_tokens(input); + let output_tokens = count_tokens(&output); + let savings = 100.0 - (output_tokens as f64 / input_tokens as f64 * 100.0); + + assert!( + savings >= 30.0, + "Bundle install: expected ≥30% savings, got {:.1}% (in={}, out={})", + savings, + input_tokens, + output_tokens + ); + } + + // ── ANSI handling test ────────────────────────────────────────────────── + + #[test] + fn test_filter_bundle_list_with_ansi() { + // ANSI-colored gem names should be stripped and parsed correctly + let output = "Gems included by the bundle:\n * \x1b[32mrails\x1b[0m (7.1.3)\n * \x1b[32mpuma\x1b[0m (6.4.2)\n"; + let result = filter_bundle_list(output); + assert!(result.contains("2 gems"), "should find 2 gems: {}", result); + assert!( + result.contains("rails"), + "should parse gem name 'rails': {}", + result + ); + assert!( + result.contains("puma"), + "should parse gem name 'puma': {}", + result + ); + } +} diff --git a/src/discover/registry.rs b/src/discover/registry.rs index c3c52edc..4ed643d7 100644 --- a/src/discover/registry.rs +++ b/src/discover/registry.rs @@ -71,6 +71,11 @@ const PATTERNS: &[&str] = &[ r"^curl\s+", r"^wget\s+", r"^(python3?\s+-m\s+)?mypy(\s|$)", + r"^(?:bundle\s+exec\s+)?rspec(?:\s|$)", + r"^(?:bundle\s+exec\s+)?rubocop(?:\s|$)", + r"^bundle\s+(list|outdated|install|update)(?:\s|$)", + r"^(?:bundle\s+exec\s+)?rails\s+(test|routes|db:migrate:status|db:migrate|db:rollback|g(?:enerate)?)(?:\s|$)", + r"^bin/rails\s+(test|routes|db:migrate:status|db:migrate|db:rollback|g(?:enerate)?)(?:\s|$)", ]; const RULES: &[RtkRule] = &[ @@ -233,6 +238,59 @@ const RULES: &[RtkRule] = &[ subcmd_savings: &[], subcmd_status: &[], }, + RtkRule { + rtk_cmd: "rtk rspec", + category: "Tests", + savings_pct: 65.0, + subcmd_savings: &[], + subcmd_status: &[], + }, + RtkRule { + rtk_cmd: "rtk rubocop", + category: "Build", + savings_pct: 65.0, + subcmd_savings: &[], + subcmd_status: &[], + }, + RtkRule { + rtk_cmd: "rtk bundle", + category: "PackageManager", + savings_pct: 20.0, + subcmd_savings: &[], + subcmd_status: &[], + }, + // Pattern: (bundle exec)?rails test/routes/db:migrate/db:rollback/generate + RtkRule { + rtk_cmd: "rtk rails", + category: "Tests", + savings_pct: 50.0, + subcmd_savings: &[ + ("test", 50.0), + ("routes", 50.0), + ("db:migrate:status", 40.0), + ("db:migrate", 40.0), + ("db:rollback", 40.0), + ("generate", 20.0), + ("g", 20.0), + ], + subcmd_status: &[], + }, + // Pattern: bin/rails test/routes/db:migrate/db:rollback/generate (same config) + RtkRule { + rtk_cmd: "rtk rails", + category: "Tests", + savings_pct: 50.0, + subcmd_savings: &[ + ("test", 50.0), + ("routes", 50.0), + ("db:migrate:status", 40.0), + ("db:migrate", 40.0), + ("db:rollback", 40.0), + ("generate", 20.0), + ("g", 20.0), + ], + subcmd_status: &[], + }, ]; /// Commands to ignore (shell builtins, trivial, already rtk). @@ -288,7 +346,9 @@ const IGNORED_PREFIXES: &[&str] = &[ "case ", ]; -const IGNORED_EXACT: &[&str] = &["cd", "echo", "true", "false", "wait", "pwd", "bash", "sh", "fi", "done"]; +const IGNORED_EXACT: &[&str] = &[ + "cd", "echo", "true", "false", "wait", "pwd", "bash", "sh", "fi", "done", +]; lazy_static! { static ref REGEX_SET: RegexSet = RegexSet::new(PATTERNS).expect("invalid regex patterns"); @@ -732,6 +792,207 @@ mod tests { assert_eq!(classify_command("done"), Classification::Ignored); } + #[test] + fn test_classify_rspec() { + match classify_command("rspec spec/models/") { + Classification::Supported { + rtk_equivalent: "rtk rspec", + .. + } => {} + other => panic!("rspec should be Supported, got {:?}", other), + } + } + + #[test] + fn test_classify_bundle_exec_rspec() { + match classify_command("bundle exec rspec") { + Classification::Supported { + rtk_equivalent: "rtk rspec", + .. + } => {} + other => panic!("bundle exec rspec should be Supported, got {:?}", other), + } + } + + #[test] + fn test_classify_rubocop() { + match classify_command("rubocop app/models/") { + Classification::Supported { + rtk_equivalent: "rtk rubocop", + .. + } => {} + other => panic!("rubocop should be Supported, got {:?}", other), + } + } + + #[test] + fn test_classify_bundle_list() { + match classify_command("bundle list") { + Classification::Supported { + rtk_equivalent: "rtk bundle", + .. + } => {} + other => panic!("bundle list should be Supported, got {:?}", other), + } + } + + #[test] + fn test_classify_bundle_outdated() { + match classify_command("bundle outdated") { + Classification::Supported { + rtk_equivalent: "rtk bundle", + .. + } => {} + other => panic!("bundle outdated should be Supported, got {:?}", other), + } + } + + #[test] + fn test_classify_rails_test() { + match classify_command("rails test") { + Classification::Supported { + rtk_equivalent: "rtk rails", + .. + } => {} + other => panic!("rails test should be Supported, got {:?}", other), + } + } + + #[test] + fn test_classify_bin_rails_routes() { + match classify_command("bin/rails routes") { + Classification::Supported { + rtk_equivalent: "rtk rails", + .. + } => {} + other => panic!("bin/rails routes should be Supported, got {:?}", other), + } + } + + #[test] + fn test_classify_bundle_exec_rails() { + match classify_command("bundle exec rails test") { + Classification::Supported { + rtk_equivalent: "rtk rails", + estimated_savings_pct, + .. + } => { + assert!( + (estimated_savings_pct - 50.0).abs() < 0.1, + "expected ~50% savings for rails test, got {}", + estimated_savings_pct + ); + } + other => panic!( + "bundle exec rails test should be Supported, got {:?}", + other + ), + } + } + + #[test] + fn test_classify_rails_db_rollback() { + match classify_command("rails db:rollback") { + Classification::Supported { + rtk_equivalent: "rtk rails", + estimated_savings_pct, + .. + } => { + assert!( + (estimated_savings_pct - 40.0).abs() < 0.1, + "expected ~40% savings for db:rollback, got {}", + estimated_savings_pct + ); + } + other => panic!("rails db:rollback should be Supported, got {:?}", other), + } + } + + #[test] + fn test_classify_rails_generate() { + match classify_command("rails generate model User") { + Classification::Supported { + rtk_equivalent: "rtk rails", + estimated_savings_pct, + .. + } => { + assert!( + (estimated_savings_pct - 20.0).abs() < 0.1, + "expected ~20% savings for generate, got {}", + estimated_savings_pct + ); + } + other => panic!("rails generate should be Supported, got {:?}", other), + } + } + + #[test] + fn test_classify_rails_g_shorthand() { + match classify_command("rails g controller Posts") { + Classification::Supported { + rtk_equivalent: "rtk rails", + estimated_savings_pct, + .. + } => { + assert!( + (estimated_savings_pct - 20.0).abs() < 0.1, + "expected ~20% savings for g shorthand, got {}", + estimated_savings_pct + ); + } + other => panic!("rails g should be Supported, got {:?}", other), + } + } + + #[test] + fn test_classify_bin_rails_db_rollback() { + match classify_command("bin/rails db:rollback") { + Classification::Supported { + rtk_equivalent: "rtk rails", + .. + } => {} + other => panic!("bin/rails db:rollback should be Supported, got {:?}", other), + } + } + + #[test] + fn test_classify_rails_db_migrate_status() { + match classify_command("rails db:migrate:status") { + Classification::Supported { + rtk_equivalent: "rtk rails", + estimated_savings_pct, + .. + } => { + assert!( + (estimated_savings_pct - 40.0).abs() < 0.1, + "expected ~40% savings for db:migrate:status, got {}", + estimated_savings_pct + ); + } + other => panic!( + "rails db:migrate:status should be Supported, got {:?}", + other + ), + } + } + + #[test] + fn test_classify_rails_db_migrate_savings() { + match classify_command("rails db:migrate") { + Classification::Supported { + estimated_savings_pct, + .. + } => { + assert!( + (estimated_savings_pct - 40.0).abs() < 0.1, + "expected ~40% savings for db:migrate, got {}", + estimated_savings_pct + ); + } + other => panic!("rails db:migrate should be Supported, got {:?}", other), + } + } + #[test] fn test_split_chain_and() { assert_eq!(split_command_chain("a && b"), vec!["a", "b"]); diff --git a/src/go_cmd.rs b/src/go_cmd.rs index 278c0ce2..42ff0cd9 100644 --- a/src/go_cmd.rs +++ b/src/go_cmd.rs @@ -267,8 +267,7 @@ fn filter_go_test_json(output: &str) -> String { // Handle build-output/build-fail events (use ImportPath, no Package) match event.action.as_str() { "build-output" => { - if let (Some(import_path), Some(output_text)) = - (&event.import_path, &event.output) + if let (Some(import_path), Some(output_text)) = (&event.import_path, &event.output) { let text = output_text.trim_end().to_string(); if !text.is_empty() { diff --git a/src/main.rs b/src/main.rs index 7125464b..3eaac6fc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +mod bundle_cmd; mod cargo_cmd; mod cc_economics; mod ccusage; @@ -36,7 +37,10 @@ mod pnpm_cmd; mod prettier_cmd; mod prisma_cmd; mod pytest_cmd; +mod rails_cmd; mod read; +mod rspec_cmd; +mod rubocop_cmd; mod ruff_cmd; mod runner; mod summary; @@ -520,6 +524,27 @@ enum Commands { args: Vec, }, + /// Bundle (Bundler) package manager with compact output (Ruby) + Bundle { + /// Bundle arguments (e.g., list, outdated, install) + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + + /// RuboCop linter with compact output (Ruby) + Rubocop { + /// RuboCop arguments (e.g., --auto-correct, -A) + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + + /// RSpec test runner with compact output (Rails/Ruby) + Rspec { + /// RSpec arguments (e.g., spec/models, --tag focus) + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Pip package manager with compact output (auto-detects uv) Pip { /// Pip arguments (e.g., list, outdated, install) @@ -541,6 +566,12 @@ enum Commands { args: Vec, }, + /// Rails commands with compact output + Rails { + #[command(subcommand)] + command: RailsCommands, + }, + /// Show hook rewrite audit metrics (requires RTK_HOOK_AUDIT=1) #[command(name = "hook-audit")] HookAudit { @@ -863,6 +894,53 @@ enum GoCommands { Other(Vec), } +#[derive(Subcommand)] +enum RailsCommands { + /// Run minitest tests with compact output (50%+ token reduction) + Test { + /// Additional rails test arguments + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Show routes with compact output (grouped by controller) + Routes { + /// Additional rails routes arguments + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Run migrations with compact output + #[command(name = "db:migrate")] + DbMigrate { + /// Additional migration arguments + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Show migration status (pending/applied) + #[command(name = "db:migrate:status")] + DbMigrateStatus { + /// Additional arguments + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Rollback migrations with compact output + #[command(name = "db:rollback")] + DbRollback { + /// Additional rollback arguments + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Run rails generate with compact output + #[command(alias = "g")] + Generate { + /// Additional generate arguments + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Passthrough: runs any unsupported rails subcommand directly + #[command(external_subcommand)] + Other(Vec), +} + fn main() -> Result<()> { let cli = Cli::parse(); @@ -1421,6 +1499,18 @@ fn main() -> Result<()> { mypy_cmd::run(&args, cli.verbose)?; } + Commands::Bundle { args } => { + bundle_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)?; } @@ -1444,6 +1534,30 @@ fn main() -> Result<()> { golangci_cmd::run(&args, cli.verbose)?; } + Commands::Rails { command } => match command { + RailsCommands::Test { args } => { + rails_cmd::run_test(&args, cli.verbose)?; + } + RailsCommands::Routes { args } => { + rails_cmd::run_routes(&args, cli.verbose)?; + } + RailsCommands::DbMigrate { args } => { + rails_cmd::run_db_migrate(&args, cli.verbose)?; + } + RailsCommands::DbMigrateStatus { args } => { + rails_cmd::run_db_migrate_status(&args, cli.verbose)?; + } + RailsCommands::DbRollback { args } => { + rails_cmd::run_db_rollback(&args, cli.verbose)?; + } + RailsCommands::Generate { args } => { + rails_cmd::run_generate(&args, cli.verbose)?; + } + RailsCommands::Other(args) => { + rails_cmd::run_other(&args, cli.verbose)?; + } + }, + Commands::HookAudit { since } => { hook_audit_cmd::run(since, cli.verbose)?; } diff --git a/src/rails_cmd.rs b/src/rails_cmd.rs new file mode 100644 index 00000000..f8629833 --- /dev/null +++ b/src/rails_cmd.rs @@ -0,0 +1,1461 @@ +//! Rails command filter. +//! +//! Sub-enum dispatch for `rails test`, `routes`, `db:migrate`, `db:migrate:status`, +//! `db:rollback`, and `generate`. Each subcommand has a specialized text parser. +//! Unrecognized subcommands pass through to rails directly. + +use crate::tracking; +use crate::utils::{exit_code_from_output, ruby_exec, truncate}; +use anyhow::{Context, Result}; +use lazy_static::lazy_static; +use regex::Regex; +use std::collections::HashMap; +use std::ffi::OsString; + +lazy_static! { + static ref RE_MIGRATED_TIME: Regex = + Regex::new(r"(?:migrated|reverted) \((\d+\.\d+)s\)").unwrap(); + static ref RE_MIGRATE_STATUS_LINE: Regex = + Regex::new(r"^\s*(up|down)\s+(\d+)\s+(.+)$").unwrap(); +} + +// ── Common rails subcommand execution ──────────────────────────────────────── + +/// Execute a rails subcommand, apply a filter to stdout, handle tee/exit code/tracking. +fn run_rails_filtered( + subcommand: &str, + args: &[String], + verbose: u8, + filter: impl Fn(&str) -> String, +) -> Result<()> { + let timer = tracking::TimedExecution::start(); + + let mut cmd = ruby_exec("rails"); + cmd.arg(subcommand).args(args); + + if verbose > 0 { + eprintln!("Running: rails {} {}", subcommand, args.join(" ")); + } + + let output = cmd + .output() + .with_context(|| format!("Failed to run rails {}. Is Rails installed?", subcommand))?; + + 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, &format!("rails {}", subcommand)); + + let filtered = if stdout.trim().is_empty() && !output.status.success() { + format!("Rails {}: FAILED (no stdout, see stderr below)", subcommand) + } else { + filter(&stdout) + }; + + let tee_label = subcommand.replace(':', "_"); + if let Some(hint) = crate::tee::tee_and_hint(&raw, &format!("rails_{}", tee_label), 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!("rails {} {}", subcommand, args.join(" ")), + &format!("rtk rails {} {}", subcommand, args.join(" ")), + &raw, + &filtered, + ); + + if !output.status.success() { + std::process::exit(exit_code); + } + + Ok(()) +} + +// ── rails test (Minitest) ──────────────────────────────────────────────────── + +#[derive(Debug, PartialEq)] +enum ParseState { + Header, + Failures, + Summary, +} + +pub fn run_test(args: &[String], verbose: u8) -> Result<()> { + run_rails_filtered("test", args, verbose, filter_minitest_output) +} + +// ── rails routes ───────────────────────────────────────────────────────────── + +pub fn run_routes(args: &[String], verbose: u8) -> Result<()> { + // Detect grep/controller flags — user is already filtering + let has_grep = args + .iter() + .any(|a| a == "-g" || a == "--grep" || a == "-c" || a == "--controller"); + + run_rails_filtered("routes", args, verbose, move |output| { + filter_rails_routes(output, has_grep) + }) +} + +// ── rails db:migrate ───────────────────────────────────────────────────────── + +pub fn run_db_migrate(args: &[String], verbose: u8) -> Result<()> { + run_rails_filtered("db:migrate", args, verbose, filter_rails_migrate) +} + +// ── rails db:migrate:status ────────────────────────────────────────────────── + +pub fn run_db_migrate_status(args: &[String], verbose: u8) -> Result<()> { + run_rails_filtered( + "db:migrate:status", + args, + verbose, + filter_rails_migrate_status, + ) +} + +// ── rails db:rollback ─────────────────────────────────────────────────────── + +pub fn run_db_rollback(args: &[String], verbose: u8) -> Result<()> { + // Reuse migrate filter -- rollback output has same format with "reverting" direction + run_rails_filtered("db:rollback", args, verbose, filter_rails_migrate) +} + +// ── rails generate ────────────────────────────────────────────────────────── + +pub fn run_generate(args: &[String], verbose: u8) -> Result<()> { + let generator_type = args + .first() + .cloned() + .unwrap_or_else(|| "generator".to_string()); + let generator_name = args.get(1).cloned().unwrap_or_default(); + + run_rails_filtered("generate", args, verbose, move |output| { + filter_rails_generate(output, &generator_type, &generator_name) + }) +} + +// ── Passthrough for other rails subcommands ────────────────────────────────── + +pub fn run_other(args: &[OsString], verbose: u8) -> Result<()> { + if args.is_empty() { + anyhow::bail!("rails: no subcommand specified"); + } + + let timer = tracking::TimedExecution::start(); + + let subcommand = args[0].to_string_lossy(); + let mut cmd = ruby_exec("rails"); + cmd.args(args); + + if verbose > 0 { + eprintln!("Running: rails {} ...", subcommand); + } + + let output = cmd + .output() + .with_context(|| format!("Failed to run rails {}", subcommand))?; + + 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, &format!("rails {}", subcommand)); + + print!("{}", stdout); + eprint!("{}", stderr); + + if let Some(hint) = crate::tee::tee_and_hint(&raw, &format!("rails_{}", subcommand), exit_code) + { + println!("{}", hint); + } + + timer.track( + &format!("rails {}", subcommand), + &format!("rtk rails {}", subcommand), + &raw, + &raw, + ); + + if !output.status.success() { + std::process::exit(exit_code); + } + + Ok(()) +} + +// ── Filter: Minitest output ────────────────────────────────────────────────── + +/// Check if a line is a non-actionable minitest noise line. +fn is_minitest_noise(line: &str) -> bool { + let t = line.trim(); + t.starts_with("Run options:") + || t.starts_with("# Running:") + || t.starts_with("Running ") + || t.starts_with("Finished in ") + // Dot-progress lines like "..F.E.": all chars are .|F|E|S and length > 1 + || (t.len() > 1 + && t.chars().all(|c| c == '.' || c == 'F' || c == 'E' || c == 'S')) +} + +/// Check if a line starts a numbered failure block: " 1) Failure:" or " 2) Error:" +fn is_numbered_minitest_failure(line: &str) -> bool { + let t = line.trim(); + if let Some(pos) = t.find(')') { + let prefix = &t[..pos]; + let suffix = t[pos + 1..].trim(); + prefix.chars().all(|c| c.is_ascii_digit()) + && !prefix.is_empty() + && (suffix.starts_with("Failure") || suffix.starts_with("Error")) + } else { + false + } +} + +fn filter_minitest_output(output: &str) -> String { + if output.trim().is_empty() { + return "Rails test: No output".to_string(); + } + + let mut state = ParseState::Header; + let mut summary_line = String::new(); + let mut failure_blocks: Vec = Vec::new(); + let mut current_failure = String::new(); + + for line in output.lines() { + let trimmed = line.trim(); + + // Skip noise lines in all states + if is_minitest_noise(line) { + continue; + } + + match state { + ParseState::Header => { + if is_numbered_minitest_failure(trimmed) + || trimmed.starts_with("Failure:") + || trimmed.starts_with("Error:") + { + state = ParseState::Failures; + current_failure.push_str(trimmed); + current_failure.push('\n'); + } else if is_summary_line(trimmed) { + summary_line = trimmed.to_string(); + state = ParseState::Summary; + } + } + ParseState::Failures => { + if is_summary_line(trimmed) { + if !current_failure.trim().is_empty() { + failure_blocks.push(compact_minitest_failure(¤t_failure)); + } + summary_line = trimmed.to_string(); + state = ParseState::Summary; + } else if is_numbered_minitest_failure(trimmed) { + // New numbered failure block + if !current_failure.trim().is_empty() { + failure_blocks.push(compact_minitest_failure(¤t_failure)); + } + current_failure = String::new(); + current_failure.push_str(trimmed); + current_failure.push('\n'); + } else if (trimmed.starts_with("Failure:") || trimmed.starts_with("Error:")) + && !current_failure.trim().is_empty() + { + failure_blocks.push(compact_minitest_failure(¤t_failure)); + current_failure = String::new(); + current_failure.push_str(trimmed); + current_failure.push('\n'); + } else if !trimmed.is_empty() { + current_failure.push_str(trimmed); + current_failure.push('\n'); + } + } + ParseState::Summary => { + break; + } + } + } + + // Capture any remaining failure block + if !current_failure.trim().is_empty() && state == ParseState::Failures { + failure_blocks.push(compact_minitest_failure(¤t_failure)); + } + + // If we found a summary line, use it + if !summary_line.is_empty() { + return build_minitest_summary(&summary_line, &failure_blocks); + } + + // Fallback: look for summary anywhere in output + for line in output.lines().rev() { + let t = line.trim(); + if is_summary_line(t) { + return build_minitest_summary(t, &failure_blocks); + } + } + + // Last resort + crate::utils::fallback_tail(output, "rails test", 5) +} + +/// Extract test name and file:line from a minitest failure block, +/// appending up to 3 truncated message lines for context. +fn compact_minitest_failure(block: &str) -> String { + let mut lines: Vec<&str> = block.lines().collect(); + lines.retain(|l| !l.trim().is_empty()); + + let mut test_name = String::new(); + let mut file_line = String::new(); + let mut message_lines: Vec = Vec::new(); + + for line in &lines { + let t = line.trim(); + + // Extract test name: "TestClass#test_name [file:line]:" or after "Failure:" / "Error:" + if t.contains('#') && t.contains('[') && t.contains(']') { + // Format: "BookingTest#test_should_validate_dates [test/models/booking_test.rb:23]:" + if let Some(bracket_start) = t.find('[') { + test_name = t[..bracket_start].trim().to_string(); + // Remove leading number and ") " prefix + if let Some(paren_pos) = test_name.find(") ") { + test_name = test_name[paren_pos + 2..].to_string(); + } + if let Some(bracket_end) = t.find(']') { + file_line = t[bracket_start + 1..bracket_end].to_string(); + } + } + } else if t.contains('#') && t.ends_with(':') { + // Format: "TestClass#test_name:" + test_name = t.trim_end_matches(':').to_string(); + if let Some(paren_pos) = test_name.find(") ") { + test_name = test_name[paren_pos + 2..].to_string(); + } + } else if t.starts_with("Failure:") || t.starts_with("Error:") { + // Unnumbered format + continue; + } else if t.starts_with("test/") || t.starts_with("./test/") { + file_line = t.to_string(); + } else { + message_lines.push(t.to_string()); + } + } + + let mut result = String::new(); + if !test_name.is_empty() { + result.push_str(&test_name); + } else if let Some(first) = message_lines.first() { + result.push_str(first); + message_lines.remove(0); + } + + if !file_line.is_empty() { + result.push_str(&format!("\n {}", file_line)); + } + + for msg in message_lines.iter().take(3) { + result.push_str(&format!("\n {}", truncate(msg, 120))); + } + + result +} + +fn is_summary_line(line: &str) -> bool { + line.contains("runs,") && line.contains("assertions,") && line.contains("failures,") +} + +fn build_minitest_summary(summary: &str, failures: &[String]) -> String { + let parts: Vec<&str> = summary.split(',').collect(); + + let runs = match extract_count(parts.first().unwrap_or(&"")) { + Some(r) => r, + None => return format!("Rails test: {}", summary), + }; + let assertions = extract_count(parts.get(1).unwrap_or(&"")).unwrap_or(0); + let failure_count = extract_count(parts.get(2).unwrap_or(&"")).unwrap_or(0); + let errors = extract_count(parts.get(3).unwrap_or(&"")).unwrap_or(0); + let skips = extract_count(parts.get(4).unwrap_or(&"")).unwrap_or(0); + + // Sanity check: if runs is 0, parsing likely failed — show raw summary + if runs == 0 { + return format!("Rails test: {}", summary); + } + + // Warn if summary mentions failures but we parsed 0 — may indicate format change + if failure_count == 0 && runs > 0 && summary.contains("failure") { + let raw_failure_part = parts.get(2).unwrap_or(&"").trim(); + if raw_failure_part.contains(|c: char| c.is_ascii_digit()) + && !raw_failure_part.starts_with('0') + { + eprintln!( + "[rtk] rails test: warning: could not parse failure count from '{}'", + raw_failure_part + ); + } + } + + // Parallel sanity check for errors field + if errors == 0 && runs > 0 && summary.contains("error") { + let raw_error_part = parts.get(3).unwrap_or(&"").trim(); + if raw_error_part.contains(|c: char| c.is_ascii_digit()) && !raw_error_part.starts_with('0') + { + eprintln!( + "[rtk] rails test: warning: could not parse error count from '{}'", + raw_error_part + ); + } + } + + if failure_count == 0 && errors == 0 { + let mut result = format!("✓ Rails test: {} passed ({} assertions)", runs, assertions); + if skips > 0 { + result.push_str(&format!(", {} skipped", skips)); + } + return result; + } + + let mut result = format!( + "Rails test: {} runs, {} failures, {} errors\n", + runs, failure_count, errors + ); + result.push_str("═══════════════════════════════════════\n"); + + for (i, failure) in failures.iter().take(5).enumerate() { + result.push_str(&format!("\n{}. ❌ {}\n", i + 1, failure)); + } + + if failures.len() > 5 { + result.push_str(&format!("\n... +{} more failures\n", failures.len() - 5)); + } + + result.trim().to_string() +} + +fn extract_count(part: &str) -> Option { + part.split_whitespace().next().and_then(|s| s.parse().ok()) +} + +// ── Filter: Rails routes ───────────────────────────────────────────────────── + +/// Known mounted engine paths +const MOUNTED_ENGINES: &[&str] = &[ + "sidekiq", + "active_storage", + "activestorage", + "action_mailbox", + "rails/conductor", + "letter_opener", + "blazer", + "flipper", + "good_job", +]; + +fn filter_rails_routes(output: &str, has_grep: bool) -> String { + if output.trim().is_empty() { + return "Rails routes: No routes found".to_string(); + } + + // Parse routes from output + let mut unparsed_count = 0; + let mut parsed_routes: Vec<(String, String, String)> = Vec::new(); // (verb, uri, controller#action) + + for line in output.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with("Prefix") || trimmed.starts_with("--") { + continue; + } + + let parts: Vec<&str> = trimmed.split_whitespace().collect(); + if parts.len() >= 3 { + let (verb_idx, verb) = parts + .iter() + .enumerate() + .find(|(_, p)| matches!(**p, "GET" | "POST" | "PUT" | "PATCH" | "DELETE")) + .map(|(i, v)| (i, *v)) + .unwrap_or((0, "")); + + if !verb.is_empty() && verb_idx + 2 <= parts.len() { + let uri = parts.get(verb_idx + 1).unwrap_or(&""); + let controller = parts.get(verb_idx + 2).unwrap_or(&""); + + // Strip (.:format) from URI + let clean_uri = uri.replace("(.:format)", ""); + + parsed_routes.push((verb.to_string(), clean_uri, controller.to_string())); + } else { + unparsed_count += 1; + } + } else { + unparsed_count += 1; + } + } + + let route_count = parsed_routes.len(); + + if route_count == 0 { + return "Rails routes: No routes found".to_string(); + } + + // Grep mode: compact whitespace, return matching routes directly + if has_grep { + let mut result = format!("Routes: {} matched\n", route_count); + for (verb, uri, ctrl) in &parsed_routes { + result.push_str(&format!(" {} {} {}\n", verb, uri, ctrl)); + } + return result.trim().to_string(); + } + + // Full mode: namespace-based grouping + // Extract namespace from controller: "admin/accesses#index" → "admin/" + let mut namespaces: HashMap> = HashMap::new(); + let mut mounted: HashMap = HashMap::new(); + + for (_verb, uri, ctrl) in &parsed_routes { + // Check for mounted engines (single scan) + if let Some(engine_name) = MOUNTED_ENGINES + .iter() + .find(|eng| uri.contains(&format!("/{}/", eng)) || ctrl.contains(*eng)) + { + *mounted.entry(engine_name.to_string()).or_insert(0) += 1; + continue; + } + + // Extract namespace and resource from controller + let ctrl_path = ctrl.split('#').next().unwrap_or(ctrl); + let parts: Vec<&str> = ctrl_path.split('/').collect(); + + let (namespace, resource) = if parts.len() > 1 { + let ns = parts[..parts.len() - 1].join("/"); + let res = parts[parts.len() - 1].to_string(); + (format!("{}/", ns), res) + } else { + ("[root]".to_string(), ctrl_path.to_string()) + }; + + *namespaces + .entry(namespace) + .or_default() + .entry(resource) + .or_insert(0) += 1; + } + + let mut result = format!("Routes: {} total\n", route_count); + + // Sort namespaces by total route count (descending) + let mut ns_totals: Vec<(String, usize, &HashMap)> = namespaces + .iter() + .map(|(ns, resources)| { + let total: usize = resources.values().sum(); + (ns.clone(), total, resources) + }) + .collect(); + ns_totals.sort_by(|a, b| b.1.cmp(&a.1)); + + for (ns, total, resources) in &ns_totals { + result.push_str(&format!("\n{} ({} routes)\n", ns, total)); + + // Sort resources by count descending + let mut res_list: Vec<(&String, &usize)> = resources.iter().collect(); + res_list.sort_by(|a, b| b.1.cmp(a.1)); + + let compact: Vec = res_list + .iter() + .take(10) + .map(|(name, count)| format!("{} ({})", name, count)) + .collect(); + result.push_str(&format!(" {}\n", compact.join(" "))); + + if res_list.len() > 10 { + result.push_str(&format!(" ... +{} more\n", res_list.len() - 10)); + } + } + + // Mounted engines + if !mounted.is_empty() { + let mut mounted_list: Vec<(String, usize)> = mounted.into_iter().collect(); + mounted_list.sort_by(|a, b| b.1.cmp(&a.1)); + for (engine, count) in &mounted_list { + result.push_str(&format!("\n[mounted] {} ({} routes)\n", engine, count)); + } + } + + if unparsed_count > 0 { + result.push_str(&format!( + "\n({} routes could not be parsed)\n", + unparsed_count + )); + } + + result.trim().to_string() +} + +// ── Filter: Rails db:migrate ───────────────────────────────────────────────── + +/// Filters both `db:migrate` and `db:rollback` output. +/// Detects direction from 'migrating'/'reverting' keywords. +fn filter_rails_migrate(output: &str) -> String { + if output.trim().is_empty() { + return "Rails migrate: No output".to_string(); + } + + let mut migrations: Vec<(String, Option)> = Vec::new(); // (name, timing) + let mut direction = "up"; + let mut first_error: Option = None; + let mut total_time: f64 = 0.0; + + for line in output.lines() { + let trimmed = line.trim(); + + if trimmed.starts_with("==") { + if trimmed.contains("migrating") { + if let Some(name) = extract_migration_name(trimmed) { + migrations.push((name, None)); + } + } else if trimmed.contains("reverting") { + direction = "down"; + if let Some(name) = extract_migration_name(trimmed) { + migrations.push((name, None)); + } + } else if trimmed.contains("migrated") || trimmed.contains("reverted") { + // Capture timing from completion lines like "migrated (0.0043s)" or "reverted (0.0035s)" + if let Some(caps) = RE_MIGRATED_TIME.captures(trimmed) { + if let Some(time_str) = caps.get(1) { + if let Ok(t) = time_str.as_str().parse::() { + total_time += t; + if let Some(last) = migrations.last_mut() { + last.1 = Some(t); + } + } + } + } + } + } else if trimmed.contains("Error:") + || trimmed.contains("Exception:") + || trimmed.contains("StandardError") + || trimmed.contains("ActiveRecord::") + || trimmed.contains("Mysql2::") + || trimmed.contains("PG::") + || trimmed.contains("SQLite3::") + { + // Capture first error line (most specific), not last (most generic) + if first_error.is_none() { + first_error = Some(truncate(trimmed, 200)); + } + } + } + + if let Some(error_msg) = &first_error { + let mut result = format!("Rails migrate: FAILED ({})\n", direction); + result.push_str("═══════════════════════════════════════\n"); + result.push_str(&format!(" {}\n", error_msg)); + if let Some(last) = migrations.last() { + result.push_str(&format!(" Failed at: {}\n", last.0)); + } + return result.trim().to_string(); + } + + if migrations.is_empty() { + if output.contains("already up") || output.contains("Schema is up to date") { + return "✓ Rails migrate: Schema is up to date".to_string(); + } + return "✓ Rails migrate: No pending migrations".to_string(); + } + + let direction_label = if direction == "down" { + "reverted" + } else { + "applied" + }; + + let mut result = if total_time > 0.0 { + format!( + "ok ✓ db:migrate ({} migrations {}, {:.2}s)\n", + migrations.len(), + direction_label, + total_time + ) + } else { + format!( + "ok ✓ db:migrate ({} migrations {})\n", + migrations.len(), + direction_label + ) + }; + + for (name, timing) in migrations.iter().take(10) { + if let Some(t) = timing { + result.push_str(&format!(" {} ({:.2}s)\n", name, t)); + } else { + result.push_str(&format!(" {}\n", name)); + } + } + if migrations.len() > 10 { + result.push_str(&format!( + " ... +{} more migrations\n", + migrations.len() - 10 + )); + } + + result.trim().to_string() +} + +fn extract_migration_name(line: &str) -> Option { + // "== 20240201120000 CreateUsersTable: migrating ==" -> "CreateUsersTable" + let stripped = line.trim_matches(|c: char| c == '=' || c.is_whitespace()); + let parts: Vec<&str> = stripped.split(':').next()?.split_whitespace().collect(); + if parts.len() >= 2 { + Some(parts[1..].join(" ")) + } else { + Some(stripped.to_string()) + } +} + +// ── Filter: Rails db:migrate:status ───────────────────────────────────────── + +fn filter_rails_migrate_status(output: &str) -> String { + if output.trim().is_empty() { + return "db:migrate:status: No output".to_string(); + } + + let mut total = 0usize; + let mut down_migrations: Vec<(String, String)> = Vec::new(); // (id, name) + + for line in output.lines() { + let trimmed = line.trim(); + if let Some(caps) = RE_MIGRATE_STATUS_LINE.captures(trimmed) { + total += 1; + let status = caps.get(1).map_or("", |m| m.as_str()); + if status == "down" { + let id = caps.get(2).map_or("", |m| m.as_str()).to_string(); + let name = caps.get(3).map_or("", |m| m.as_str()).trim().to_string(); + down_migrations.push((id, name)); + } + } + } + + if total == 0 { + return "db:migrate:status: No migrations found".to_string(); + } + + if down_migrations.is_empty() { + return format!("db:migrate:status — {} migrations (all up)", total); + } + + let mut result = format!( + "db:migrate:status — {} migrations ({} pending)\n", + total, + down_migrations.len() + ); + for (id, name) in &down_migrations { + result.push_str(&format!(" down {} {}\n", id, name)); + } + + result.trim().to_string() +} + +// ── Filter: Rails generate ────────────────────────────────────────────────── + +fn filter_rails_generate(output: &str, generator_type: &str, generator_name: &str) -> String { + if output.trim().is_empty() { + return format!( + "ok ✓ rails g {} {} (no output)", + generator_type, generator_name + ); + } + + let mut created: Vec = Vec::new(); + let mut removed: Vec = Vec::new(); + let mut skipped: Vec = Vec::new(); + let mut conflicts: Vec = Vec::new(); + + for line in output.lines() { + let trimmed = line.trim(); + if let Some(rest) = trimmed.strip_prefix("create") { + created.push(rest.trim().to_string()); + } else if let Some(rest) = trimmed.strip_prefix("remove") { + removed.push(rest.trim().to_string()); + } else if let Some(rest) = trimmed.strip_prefix("skip") { + skipped.push(rest.trim().to_string()); + } else if let Some(rest) = trimmed.strip_prefix("conflict") { + conflicts.push(rest.trim().to_string()); + } + // Skip "invoke" lines — they add no actionable info + } + + let total_files = created.len() + removed.len(); + let action = if !removed.is_empty() && created.is_empty() { + "destroy" + } else { + "g" + }; + + let mut result = format!( + "ok ✓ rails {} {} {} ({} files)\n", + action, generator_type, generator_name, total_files + ); + + for file in &created { + result.push_str(&format!(" create {}\n", file)); + } + for file in &removed { + result.push_str(&format!(" remove {}\n", file)); + } + if !skipped.is_empty() { + result.push_str(&format!(" ({} skipped)\n", skipped.len())); + } + if !conflicts.is_empty() { + for file in &conflicts { + result.push_str(&format!(" conflict {}\n", file)); + } + } + + result.trim().to_string() +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::utils::count_tokens; + + // ── Minitest tests ─────────────────────────────────────────────────────── + + #[test] + fn test_filter_minitest_all_pass() { + let output = r#"Running 5 tests in a single process (parallelized) +Run options: --seed 12345 + +# Running: + +..... + +Finished in 0.1234s, 40.5195 runs/s, 80.0 assertions/s. +5 runs, 10 assertions, 0 failures, 0 errors, 0 skips +"#; + let result = filter_minitest_output(output); + assert!(result.starts_with("✓ Rails test:")); + assert!(result.contains("5 passed")); + assert!(result.contains("10 assertions")); + } + + #[test] + fn test_filter_minitest_with_failures() { + let output = r#"Run options: --seed 54321 + +# Running: + +..F. + +Failure: +UsersControllerTest#test_should_create_user [test/controllers/users_controller_test.rb:25]: +Expected: true + Actual: false + +4 runs, 8 assertions, 1 failures, 0 errors, 0 skips +"#; + let result = filter_minitest_output(output); + assert!(result.contains("1 failures")); + assert!(result.contains("❌")); + assert!(result.contains("UsersControllerTest")); + } + + #[test] + fn test_filter_minitest_no_output() { + let result = filter_minitest_output(""); + assert_eq!(result, "Rails test: No output"); + } + + #[test] + fn test_filter_minitest_with_errors() { + let output = r#"Run options: --seed 11111 + +# Running: + +E. + +Error: +UsersControllerTest#test_should_show_user: +NoMethodError: undefined method `name' for nil + test/controllers/users_controller_test.rb:15 + +2 runs, 3 assertions, 0 failures, 1 errors, 0 skips +"#; + let result = filter_minitest_output(output); + assert!(result.contains("1 errors")); + assert!(result.contains("❌")); + } + + #[test] + fn test_filter_minitest_with_skips() { + let output = "3 runs, 5 assertions, 0 failures, 0 errors, 2 skips\n"; + let result = filter_minitest_output(output); + assert!(result.contains("✓ Rails test:")); + assert!(result.contains("2 skipped")); + } + + #[test] + fn test_is_summary_line() { + assert!(is_summary_line( + "5 runs, 10 assertions, 0 failures, 0 errors, 0 skips" + )); + assert!(!is_summary_line("Running tests...")); + assert!(!is_summary_line("Finished in 0.1234s")); + } + + #[test] + fn test_extract_count() { + assert_eq!(extract_count("5 runs"), Some(5)); + assert_eq!(extract_count(" 10 assertions"), Some(10)); + assert_eq!(extract_count(" 0 failures"), Some(0)); + assert_eq!(extract_count(""), None); + } + + #[test] + fn test_filter_minitest_numbered_failures() { + let output = r#"Run options: --seed 99999 + +# Running: + +...F..E. + + 1) Failure: +BookingTest#test_should_validate_dates [test/models/booking_test.rb:23]: +Expected: true + Actual: false + + 2) Error: +UserTest#test_should_send_email [test/models/user_test.rb:45]: +NoMethodError: undefined method `deliver_now' for nil + test/models/user_test.rb:46 + test/models/user_test.rb:12 + +8 runs, 15 assertions, 1 failures, 1 errors, 0 skips +"#; + let result = filter_minitest_output(output); + // Should detect numbered failure format + assert!(result.contains("❌")); + assert!(result.contains("1 failures")); + assert!(result.contains("1 errors")); + assert!(result.contains("BookingTest#test_should_validate_dates")); + assert!(result.contains("test/models/booking_test.rb:23")); + assert!(result.contains("UserTest#test_should_send_email")); + // Noise lines stripped + assert!(!result.contains("Run options:")); + assert!(!result.contains("# Running:")); + assert!(!result.contains("...F..E.")); + } + + #[test] + fn test_filter_minitest_clean_failure_format() { + let output = r#"Running 3 tests in a single process (parallelized) +Run options: --seed 42 + +# Running: + +..F + + 1) Failure: +OrdersControllerTest#test_should_create_order [test/controllers/orders_controller_test.rb:18]: +Expected response to be a <2XX: success>, but was a <422: Unprocessable Entity> + +3 runs, 6 assertions, 1 failures, 0 errors, 0 skips +"#; + let result = filter_minitest_output(output); + // Clean format: test name on one line, file:line indented below, message indented below + assert!(result.contains("OrdersControllerTest#test_should_create_order")); + assert!(result.contains("test/controllers/orders_controller_test.rb:18")); + assert!(result.contains("422")); + // Noise stripped + assert!(!result.contains("Run options:")); + assert!(!result.contains("# Running:")); + assert!(!result.contains("..F")); + assert!(!result.contains("Finished in")); + } + + #[test] + fn test_filter_minitest_strips_noise_on_pass() { + let output = r#"Running 10 tests in a single process (parallelized) +Run options: --seed 55555 + +# Running: + +.......... + +Finished in 2.3456s, 4.2667 runs/s, 17.0666 assertions/s. +10 runs, 40 assertions, 0 failures, 0 errors, 0 skips +"#; + let result = filter_minitest_output(output); + assert!(result.starts_with("✓ Rails test:")); + assert!(result.contains("10 passed")); + // All noise lines stripped + assert!(!result.contains("Run options:")); + assert!(!result.contains("# Running:")); + assert!(!result.contains("..........")); + assert!(!result.contains("Finished in")); + } + + #[test] + fn test_minitest_token_savings() { + let input = r#"Running 20 tests in a single process (parallelized) +Run options: --seed 12345 + +# Running: + +..........F.......E. + + 1) Failure: +BookingTest#test_should_validate_dates [test/models/booking_test.rb:23]: +Expected: true + Actual: false + + 2) Error: +UserTest#test_should_send_email [test/models/user_test.rb:45]: +NoMethodError: undefined method `deliver_now' for nil + test/models/user_test.rb:46 + app/models/user.rb:31 + test/models/user_test.rb:12 + +Finished in 5.6789s, 3.5224 runs/s, 14.0896 assertions/s. +20 runs, 80 assertions, 1 failures, 1 errors, 0 skips +"#; + let output = filter_minitest_output(input); + let input_tokens = count_tokens(input); + let output_tokens = count_tokens(&output); + let savings = 100.0 - (output_tokens as f64 / input_tokens as f64 * 100.0); + assert!( + savings >= 35.0, + "Minitest filter: expected ≥35% savings, got {:.1}%", + savings + ); + } + + // ── Routes tests ───────────────────────────────────────────────────────── + + #[test] + fn test_filter_rails_routes() { + let output = r#" Prefix Verb URI Pattern Controller#Action + root GET / pages#home + users GET /users(.:format) users#index + POST /users(.:format) users#create + new_user GET /users/new(.:format) users#new + edit_user GET /users/:id/edit(.:format) users#edit + user GET /users/:id(.:format) users#show + PATCH /users/:id(.:format) users#update + PUT /users/:id(.:format) users#update + DELETE /users/:id(.:format) users#destroy + posts GET /posts(.:format) posts#index + post GET /posts/:id(.:format) posts#show +"#; + let result = filter_rails_routes(output, false); + assert!(result.contains("11 total")); + assert!(result.contains("users")); + assert!(result.contains("posts")); + assert!(result.contains("pages")); + } + + #[test] + fn test_filter_rails_routes_empty() { + let result = filter_rails_routes("", false); + assert!(result.contains("No routes found")); + } + + #[test] + fn test_filter_rails_routes_namespace_grouping() { + let output = r#" Prefix Verb URI Pattern Controller#Action + admin_users GET /admin/users(.:format) admin/users#index + admin_user GET /admin/users/:id(.:format) admin/users#show + admin_posts GET /admin/posts(.:format) admin/posts#index + admin_post GET /admin/posts/:id(.:format) admin/posts#show + users GET /users(.:format) users#index + user GET /users/:id(.:format) users#show +"#; + let result = filter_rails_routes(output, false); + assert!(result.contains("6 total")); + // Namespace grouping + assert!(result.contains("admin/"), "should group admin namespace"); + assert!(result.contains("[root]"), "should group root-level routes"); + } + + #[test] + fn test_filter_rails_routes_grep_mode() { + let output = r#" Prefix Verb URI Pattern Controller#Action + admin_users GET /admin/users(.:format) admin/users#index + admin_user GET /admin/users/:id(.:format) admin/users#show +"#; + let result = filter_rails_routes(output, true); + assert!(result.contains("2 matched")); + // Grep mode: compact, per-route listing + assert!(result.contains("GET /admin/users")); + assert!(!result.contains("(.:format)"), "should strip format suffix"); + } + + #[test] + fn test_filter_rails_routes_strips_format() { + let output = r#" Prefix Verb URI Pattern Controller#Action + users GET /users(.:format) users#index +"#; + let result = filter_rails_routes(output, true); + assert!(!result.contains("(.:format)")); + assert!(result.contains("/users")); + } + + #[test] + fn test_filter_rails_routes_mounted_engines() { + let output = r#" Prefix Verb URI Pattern Controller#Action + users GET /users(.:format) users#index + sidekiq GET /sidekiq/busy(.:format) sidekiq/web#busy + GET /sidekiq/queues(.:format) sidekiq/web#queues +"#; + let result = filter_rails_routes(output, false); + assert!(result.contains("[mounted] sidekiq")); + } + + // ── Migration tests ────────────────────────────────────────────────────── + + #[test] + fn test_filter_rails_migrate_success_with_timing() { + let output = r#"== 20240201120000 CreateUsersTable: migrating ================================= +-- create_table(:users) + -> 0.0042s +== 20240201120000 CreateUsersTable: migrated (0.0043s) ======================== + +== 20240202110000 AddEmailIndexToUsers: migrating ============================ +-- add_index(:users, :email, {:unique=>true}) + -> 0.0021s +== 20240202110000 AddEmailIndexToUsers: migrated (0.0022s) ==================== +"#; + let result = filter_rails_migrate(output); + assert!( + result.contains("ok ✓ db:migrate"), + "should have ok marker: {}", + result + ); + assert!(result.contains("2 migrations applied")); + assert!(result.contains("CreateUsersTable")); + assert!(result.contains("AddEmailIndexToUsers")); + // Should show per-migration timing + assert!(result.contains("0.00s") || result.contains("(0.00")); + // Should show total time + assert!(result.contains("0.01s") || result.contains("0.00s")); + } + + #[test] + fn test_filter_rails_migrate_no_pending() { + let result = filter_rails_migrate(""); + assert!(result.contains("No output") || result.contains("No pending migrations")); + } + + #[test] + fn test_filter_rails_migrate_already_up() { + let output = "Schema is up to date.\n"; + let result = filter_rails_migrate(output); + assert!(result.contains("up to date")); + } + + #[test] + fn test_extract_migration_name() { + assert_eq!( + extract_migration_name("== 20240201120000 CreateUsersTable: migrating =="), + Some("CreateUsersTable".to_string()) + ); + assert_eq!( + extract_migration_name("== 20240202110000 AddEmailIndexToUsers: migrating =="), + Some("AddEmailIndexToUsers".to_string()) + ); + } + + // ── db:migrate:status tests ───────────────────────────────────────────── + + #[test] + fn test_filter_rails_migrate_status_all_up() { + let output = r#"database: myapp_development + + Status Migration ID Migration Name +-------------------------------------------------- + up 20200101120000 Create users + up 20200102120000 Create parkings + up 20200103120000 Add status to parkings +"#; + let result = filter_rails_migrate_status(output); + assert!(result.contains("3 migrations")); + assert!(result.contains("all up")); + } + + #[test] + fn test_filter_rails_migrate_status_pending() { + let output = r#"database: myapp_development + + Status Migration ID Migration Name +-------------------------------------------------- + up 20200101120000 Create users + up 20200102120000 Create parkings + down 20260228120000 Add status to bookings + down 20260228130000 Create short term price lists +"#; + let result = filter_rails_migrate_status(output); + assert!(result.contains("4 migrations")); + assert!(result.contains("2 pending")); + assert!(result.contains("down 20260228120000 Add status to bookings")); + assert!(result.contains("down 20260228130000 Create short term price lists")); + // Should NOT include "up" migrations + assert!(!result.contains("Create users")); + } + + // ── db:rollback tests ─────────────────────────────────────────────────── + + #[test] + fn test_filter_rails_rollback() { + let output = r#"== 20260228130000 CreateShortTermPriceLists: reverting ========================= +-- drop_table(:short_term_price_lists) + -> 0.0034s +== 20260228130000 CreateShortTermPriceLists: reverted (0.0035s) ================ +"#; + let result = filter_rails_migrate(output); + assert!(result.contains("ok ✓ db:migrate")); + assert!(result.contains("reverted")); + assert!(result.contains("CreateShortTermPriceLists")); + } + + // ── rails generate tests ──────────────────────────────────────────────── + + #[test] + fn test_filter_rails_generate_model() { + let output = r#" invoke active_record + create db/migrate/20260228120000_create_short_term_price_lists.rb + create app/models/short_term_price_list.rb + invoke rspec + create spec/models/short_term_price_list_spec.rb + invoke factory_bot + create spec/factories/short_term_price_lists.rb +"#; + let result = filter_rails_generate(output, "model", "ShortTermPriceList"); + assert!(result.contains("ok ✓ rails g model ShortTermPriceList")); + assert!(result.contains("4 files")); + assert!(result.contains("create db/migrate")); + assert!(result.contains("create app/models")); + // Should not contain "invoke" lines + assert!(!result.contains("invoke")); + } + + #[test] + fn test_filter_rails_generate_destroy() { + let output = r#" invoke active_record + remove db/migrate/20260228120000_create_short_term_price_lists.rb + remove app/models/short_term_price_list.rb + invoke rspec + remove spec/models/short_term_price_list_spec.rb +"#; + let result = filter_rails_generate(output, "model", "ShortTermPriceList"); + assert!(result.contains("rails destroy")); + assert!(result.contains("3 files")); + assert!(result.contains("remove db/migrate")); + } + + #[test] + fn test_filter_rails_generate_with_skip() { + let output = r#" invoke active_record + create db/migrate/20260228120000_create_things.rb + create app/models/thing.rb + invoke rspec + skip spec/models/thing_spec.rb +"#; + let result = filter_rails_generate(output, "model", "Thing"); + assert!(result.contains("2 files")); + assert!(result.contains("1 skipped")); + } + + // ── Token savings ──────────────────────────────────────────────────────── + + #[test] + fn test_token_savings_minitest() { + let input = r#"Running 50 tests in a single process (parallelized) +Run options: --seed 12345 + +# Running: + +.................................................. + +Finished in 2.5s, 20.0 runs/s, 40.0 assertions/s. +50 runs, 100 assertions, 0 failures, 0 errors, 0 skips +"#; + let output = filter_minitest_output(input); + + let input_tokens = count_tokens(input); + let output_tokens = count_tokens(&output); + let savings = 100.0 - (output_tokens as f64 / input_tokens as f64 * 100.0); + + assert!( + savings >= 50.0, + "Minitest: expected ≥50% savings, got {:.1}%", + savings + ); + } + + #[test] + fn test_token_savings_routes() { + let input = r#" Prefix Verb URI Pattern Controller#Action + root GET / pages#home + users GET /users(.:format) users#index + POST /users(.:format) users#create + new_user GET /users/new(.:format) users#new + edit_user GET /users/:id/edit(.:format) users#edit + user GET /users/:id(.:format) users#show + PATCH /users/:id(.:format) users#update + PUT /users/:id(.:format) users#update + DELETE /users/:id(.:format) users#destroy + posts GET /posts(.:format) posts#index + POST /posts(.:format) posts#create + new_post GET /posts/new(.:format) posts#new + edit_post GET /posts/:id/edit(.:format) posts#edit + post GET /posts/:id(.:format) posts#show + PATCH /posts/:id(.:format) posts#update + PUT /posts/:id(.:format) posts#update + DELETE /posts/:id(.:format) posts#destroy +"#; + let output = filter_rails_routes(input, false); + + 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 >= 50.0, + "Routes: expected ≥50% savings, got {:.1}%", + savings + ); + } + + #[test] + fn test_token_savings_migrate() { + let input = r#"== 20240201120000 CreateUsersTable: migrating ================================= +-- create_table(:users) + -> 0.0042s +== 20240201120000 CreateUsersTable: migrated (0.0043s) ======================== + +== 20240202110000 AddEmailIndexToUsers: migrating ============================ +-- add_index(:users, :email, {:unique=>true}) + -> 0.0021s +== 20240202110000 AddEmailIndexToUsers: migrated (0.0022s) ==================== + +== 20240203100000 CreatePostsTable: migrating ================================= +-- create_table(:posts) + -> 0.0035s +== 20240203100000 CreatePostsTable: migrated (0.0036s) ======================== +"#; + let output = filter_rails_migrate(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 >= 40.0, + "Migrate: expected ≥40% savings, got {:.1}%", + savings + ); + } + + #[test] + fn test_filter_rails_migrate_error() { + let input = r#"== 20240201120000 CreateUsersTable: migrating ================================= +-- create_table(:users) +ActiveRecord::StatementInvalid: PG::DuplicateTable: ERROR: relation "users" already exists +/app/db/migrate/20240201120000_create_users_table.rb:3:in `change' +"#; + let output = filter_rails_migrate(input); + assert!( + output.contains("FAILED"), + "should detect migration failure: {}", + output + ); + assert!( + output.contains("CreateUsersTable"), + "should name the failing migration: {}", + output + ); + assert!( + output.contains("DuplicateTable") || output.contains("already exists"), + "should include first error detail: {}", + output + ); + } + + #[test] + fn test_token_savings_generate() { + let input = r#" invoke active_record + create db/migrate/20260301120000_create_posts.rb + create app/models/post.rb + invoke test_unit + create test/models/post_test.rb + create test/fixtures/posts.yml + invoke resource_route + route resources :posts + invoke scaffold_controller + create app/controllers/posts_controller.rb + invoke erb + create app/views/posts + create app/views/posts/index.html.erb + create app/views/posts/edit.html.erb + create app/views/posts/show.html.erb + create app/views/posts/new.html.erb + create app/views/posts/_form.html.erb + create app/views/posts/_post.html.erb + invoke resource_route + invoke test_unit + create test/controllers/posts_controller_test.rb + create test/system/posts_test.rb + invoke helper + create app/helpers/posts_helper.rb + invoke test_unit + invoke jbuilder + create app/views/posts/index.json.jbuilder + create app/views/posts/show.json.jbuilder + create app/views/posts/_post.json.jbuilder +"#; + let output = filter_rails_generate(input, "scaffold", "post"); + + 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 >= 20.0, + "Rails generate: expected ≥20% savings, got {:.1}% (in={}, out={})", + savings, + input_tokens, + output_tokens + ); + } + + // ── ANSI handling test ────────────────────────────────────────────────── + + #[test] + fn test_filter_minitest_ansi_dot_progress() { + // ANSI-colored dot progress line should not break filtering + let output = "Run options: --seed 12345\n\n# Running:\n\n\x1b[32m.\x1b[0m\x1b[32m.\x1b[0m\x1b[32m.\x1b[0m\n\n3 runs, 6 assertions, 0 failures, 0 errors, 0 skips\n"; + let result = filter_minitest_output(output); + assert!(result.contains("✓ Rails test:")); + assert!(result.contains("3 passed")); + } + + // ── Empty migration table test (Issue 11) ─────────────────────────────── + + #[test] + fn test_filter_rails_migrate_status_empty() { + let output = "database: myapp_development\n\n Status Migration ID Migration Name\n--------------------------------------------------\n"; + let result = filter_rails_migrate_status(output); + assert!( + result.contains("No migrations found"), + "should report no migrations for empty table: {}", + result + ); + } +} diff --git a/src/rspec_cmd.rs b/src/rspec_cmd.rs new file mode 100644 index 00000000..9052dacd --- /dev/null +++ b/src/rspec_cmd.rs @@ -0,0 +1,1002 @@ +//! RSpec test runner filter. +//! +//! Injects `--format json` to get structured output, parses it to show only +//! failures. Falls back to a state-machine text parser when JSON is unavailable +//! (e.g., user specified `--format documentation`) or when injected JSON output +//! fails to parse. + +use crate::tracking; +use crate::utils::{exit_code_from_output, fallback_tail, ruby_exec, truncate}; +use anyhow::{Context, Result}; +use lazy_static::lazy_static; +use regex::Regex; +use serde::Deserialize; + +// ── Noise-stripping regex patterns ────────────────────────────────────────── + +lazy_static! { + static ref RE_SPRING: Regex = Regex::new(r"(?i)running via spring preloader").unwrap(); + static ref RE_SIMPLECOV: Regex = + Regex::new(r"(?i)(coverage report|simplecov|coverage/|\.simplecov|All Files.*Lines)") + .unwrap(); + static ref RE_DEPRECATION: Regex = Regex::new(r"^DEPRECATION WARNING:").unwrap(); + static ref RE_FINISHED_IN: Regex = Regex::new(r"^Finished in \d").unwrap(); + static ref RE_SCREENSHOT: Regex = Regex::new(r"saved screenshot to (.+)").unwrap(); + static ref RE_RSPEC_SUMMARY: Regex = Regex::new(r"(\d+) examples?, (\d+) failures?").unwrap(); +} + +// ── JSON structures matching RSpec's --format json output ─────────────────── + +#[derive(Deserialize)] +struct RspecOutput { + examples: Vec, + summary: RspecSummary, +} + +#[derive(Deserialize)] +struct RspecExample { + full_description: String, + status: String, + file_path: String, + line_number: u32, + exception: Option, +} + +#[derive(Deserialize)] +struct RspecException { + class: String, + message: String, + backtrace: Vec, +} + +#[derive(Deserialize)] +struct RspecSummary { + duration: f64, + example_count: usize, + failure_count: usize, + pending_count: usize, + #[serde(default)] + errors_outside_of_examples_count: usize, +} + +// ── Public entry point ─────────────────────────────────────────────────────── + +pub fn run(args: &[String], verbose: u8) -> Result<()> { + let timer = tracking::TimedExecution::start(); + + let mut cmd = ruby_exec("rspec"); + + // Inject --format json unless the user already specified a format + let has_format = args + .iter() + .any(|a| a.starts_with("--format") || a.starts_with("-f")); + + if !has_format { + cmd.arg("--format").arg("json"); + } + + cmd.args(args); + + if verbose > 0 { + let injected = if has_format { "" } else { " --format json" }; + eprintln!("Running: rspec{} {}", injected, args.join(" ")); + } + + let output = cmd.output().context( + "Failed to run rspec. Is it installed? Try: gem install rspec or add it to your Gemfile", + )?; + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let raw = format!("{}\n{}", stdout, stderr); + + let exit_code = exit_code_from_output(&output, "rspec"); + + let filtered = if stdout.trim().is_empty() && !output.status.success() { + "RSpec: FAILED (no stdout, see stderr below)".to_string() + } else if has_format { + // User specified format — use text fallback on stripped output + let stripped = strip_noise(&stdout); + filter_rspec_text(&stripped) + } else { + filter_rspec_output(&stdout) + }; + + if let Some(hint) = crate::tee::tee_and_hint(&raw, "rspec", exit_code) { + println!("{}\n{}", filtered, hint); + } else { + println!("{}", filtered); + } + + if !stderr.trim().is_empty() && (!output.status.success() || verbose > 0) { + eprintln!("{}", stderr.trim()); + } + + timer.track( + &format!("rspec {}", args.join(" ")), + &format!("rtk rspec {}", args.join(" ")), + &raw, + &filtered, + ); + + if !output.status.success() { + std::process::exit(exit_code); + } + + Ok(()) +} + +// ── Noise stripping ───────────────────────────────────────────────────────── + +/// Remove noise lines: Spring preloader, SimpleCov, DEPRECATION warnings, +/// "Finished in" timing line, and Capybara screenshot details (keep path only). +fn strip_noise(output: &str) -> String { + let mut result = Vec::new(); + let mut in_simplecov_block = false; + + for line in output.lines() { + let trimmed = line.trim(); + + // Skip Spring preloader messages + if RE_SPRING.is_match(trimmed) { + continue; + } + + // Skip lines starting with "DEPRECATION WARNING:" (single-line only) + if RE_DEPRECATION.is_match(trimmed) { + continue; + } + + // Skip "Finished in N seconds" line + if RE_FINISHED_IN.is_match(trimmed) { + continue; + } + + // SimpleCov block detection: once we see it, skip until blank line + if RE_SIMPLECOV.is_match(trimmed) { + in_simplecov_block = true; + continue; + } + if in_simplecov_block { + if trimmed.is_empty() { + in_simplecov_block = false; + } + continue; + } + + // Capybara screenshots: keep only the path + if let Some(caps) = RE_SCREENSHOT.captures(trimmed) { + if let Some(path) = caps.get(1) { + result.push(format!("[screenshot: {}]", path.as_str().trim())); + continue; + } + } + + result.push(line.to_string()); + } + + result.join("\n") +} + +// ── Output filtering ───────────────────────────────────────────────────────── + +fn filter_rspec_output(output: &str) -> String { + if output.trim().is_empty() { + return "RSpec: No output".to_string(); + } + + // Try parsing as JSON first (happy path when --format json is injected) + if let Ok(rspec) = serde_json::from_str::(output) { + return build_rspec_summary(&rspec); + } + + // Strip noise (Spring, SimpleCov, etc.) and retry JSON parse + let stripped = strip_noise(output); + match serde_json::from_str::(&stripped) { + Ok(rspec) => return build_rspec_summary(&rspec), + Err(e) => { + eprintln!( + "[rtk] rspec: JSON parse failed ({}), using text fallback", + e + ); + } + } + + filter_rspec_text(&stripped) +} + +fn build_rspec_summary(rspec: &RspecOutput) -> String { + let s = &rspec.summary; + + if s.example_count == 0 && s.errors_outside_of_examples_count == 0 { + return "RSpec: No examples found".to_string(); + } + + if s.example_count == 0 && s.errors_outside_of_examples_count > 0 { + return format!( + "RSpec: {} errors outside of examples ({:.2}s)", + s.errors_outside_of_examples_count, s.duration + ); + } + + if s.failure_count == 0 && s.errors_outside_of_examples_count == 0 { + let passed = s.example_count.saturating_sub(s.pending_count); + let mut result = format!("✓ RSpec: {} passed", passed); + if s.pending_count > 0 { + result.push_str(&format!(", {} pending", s.pending_count)); + } + result.push_str(&format!(" ({:.2}s)", s.duration)); + return result; + } + + let passed = s + .example_count + .saturating_sub(s.failure_count + s.pending_count); + let mut result = format!("RSpec: {} passed, {} failed", passed, s.failure_count); + if s.pending_count > 0 { + result.push_str(&format!(", {} pending", s.pending_count)); + } + result.push_str(&format!(" ({:.2}s)\n", s.duration)); + result.push_str("═══════════════════════════════════════\n"); + + let failures: Vec<&RspecExample> = rspec + .examples + .iter() + .filter(|e| e.status == "failed") + .collect(); + + if failures.is_empty() { + return result.trim().to_string(); + } + + result.push_str("\nFailures:\n"); + + for (i, example) in failures.iter().take(5).enumerate() { + result.push_str(&format!( + "{}. ❌ {}\n {}:{}\n", + i + 1, + example.full_description, + example.file_path, + example.line_number + )); + + if let Some(exc) = &example.exception { + let short_class = exc.class.split("::").last().unwrap_or(&exc.class); + let first_msg = exc.message.lines().next().unwrap_or(""); + result.push_str(&format!( + " {}: {}\n", + short_class, + truncate(first_msg, 120) + )); + + // First backtrace line not from gems/rspec internals + for bt in &exc.backtrace { + if !bt.contains("/gems/") && !bt.contains("lib/rspec") { + result.push_str(&format!(" {}\n", truncate(bt, 120))); + break; + } + } + } + + if i < failures.len().min(5) - 1 { + result.push('\n'); + } + } + + if failures.len() > 5 { + result.push_str(&format!("\n... +{} more failures\n", failures.len() - 5)); + } + + result.trim().to_string() +} + +/// State machine text fallback parser for when JSON is unavailable. +fn filter_rspec_text(output: &str) -> String { + #[derive(PartialEq)] + enum State { + Header, + Failures, + FailedExamples, + Summary, + } + + let mut state = State::Header; + let mut failures: Vec = Vec::new(); + let mut current_failure = String::new(); + let mut summary_line = String::new(); + + for line in output.lines() { + let trimmed = line.trim(); + + match state { + State::Header => { + if trimmed == "Failures:" { + state = State::Failures; + } else if trimmed == "Failed examples:" { + state = State::FailedExamples; + } else if RE_RSPEC_SUMMARY.is_match(trimmed) { + summary_line = trimmed.to_string(); + state = State::Summary; + } + } + State::Failures => { + // New failure block starts with numbered pattern like " 1) ..." + if is_numbered_failure(trimmed) { + if !current_failure.trim().is_empty() { + failures.push(compact_failure_block(¤t_failure)); + } + current_failure = trimmed.to_string(); + current_failure.push('\n'); + } else if trimmed == "Failed examples:" { + if !current_failure.trim().is_empty() { + failures.push(compact_failure_block(¤t_failure)); + } + current_failure.clear(); + state = State::FailedExamples; + } else if RE_RSPEC_SUMMARY.is_match(trimmed) { + if !current_failure.trim().is_empty() { + failures.push(compact_failure_block(¤t_failure)); + } + current_failure.clear(); + summary_line = trimmed.to_string(); + state = State::Summary; + } else if !trimmed.is_empty() { + // Skip gem-internal backtrace lines + if is_gem_backtrace(trimmed) { + continue; + } + current_failure.push_str(trimmed); + current_failure.push('\n'); + } + } + State::FailedExamples => { + if RE_RSPEC_SUMMARY.is_match(trimmed) { + summary_line = trimmed.to_string(); + state = State::Summary; + } + // Skip "Failed examples:" section (just rspec commands to re-run) + } + State::Summary => { + break; + } + } + } + + // Capture remaining failure + if !current_failure.trim().is_empty() && state == State::Failures { + failures.push(compact_failure_block(¤t_failure)); + } + + // If we found a summary line, build result + if !summary_line.is_empty() { + if failures.is_empty() { + return format!("RSpec: {}", summary_line); + } + let mut result = format!("RSpec: {}\n", summary_line); + result.push_str("═══════════════════════════════════════\n\n"); + for (i, failure) in failures.iter().take(5).enumerate() { + result.push_str(&format!("{}. ❌ {}\n", i + 1, failure)); + if i < failures.len().min(5) - 1 { + result.push('\n'); + } + } + if failures.len() > 5 { + result.push_str(&format!("\n... +{} more failures\n", failures.len() - 5)); + } + return result.trim().to_string(); + } + + // Fallback: look for summary anywhere + for line in output.lines().rev() { + let t = line.trim(); + if t.contains("example") && (t.contains("failure") || t.contains("pending")) { + return format!("RSpec: {}", t); + } + } + + // Last resort: last 5 lines + fallback_tail(output, "rspec", 5) +} + +/// Check if a line is a numbered failure like "1) User#full_name..." +fn is_numbered_failure(line: &str) -> bool { + let trimmed = line.trim(); + if let Some(pos) = trimmed.find(')') { + let prefix = &trimmed[..pos]; + prefix.chars().all(|c| c.is_ascii_digit()) && !prefix.is_empty() + } else { + false + } +} + +/// Check if a backtrace line is from gems/rspec internals. +fn is_gem_backtrace(line: &str) -> bool { + line.contains("/gems/") + || line.contains("lib/rspec") + || line.contains("lib/ruby/") + || line.contains("vendor/bundle") +} + +/// Compact a failure block: extract key info, strip verbose backtrace. +fn compact_failure_block(block: &str) -> String { + let mut lines: Vec<&str> = block.lines().collect(); + + // Remove empty lines + lines.retain(|l| !l.trim().is_empty()); + + // Extract spec file:line (lines starting with # ./spec/ or # ./test/) + let mut spec_file = String::new(); + let mut kept_lines: Vec = Vec::new(); + + for line in &lines { + let t = line.trim(); + if t.starts_with("# ./spec/") || t.starts_with("# ./test/") { + spec_file = t.trim_start_matches("# ").to_string(); + } else if t.starts_with('#') && (t.contains("/gems/") || t.contains("lib/rspec")) { + // Skip gem backtrace + continue; + } else { + kept_lines.push(t.to_string()); + } + } + + let mut result = kept_lines.join("\n "); + if !spec_file.is_empty() { + result.push_str(&format!("\n {}", spec_file)); + } + result +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::utils::count_tokens; + + fn all_pass_json() -> &'static str { + r#"{ + "version": "3.12.0", + "examples": [ + { + "id": "./spec/models/user_spec.rb[1:1]", + "description": "is valid with valid attributes", + "full_description": "User is valid with valid attributes", + "status": "passed", + "file_path": "./spec/models/user_spec.rb", + "line_number": 5, + "run_time": 0.001234, + "pending_message": null, + "exception": null + }, + { + "id": "./spec/models/user_spec.rb[1:2]", + "description": "validates email format", + "full_description": "User validates email format", + "status": "passed", + "file_path": "./spec/models/user_spec.rb", + "line_number": 12, + "run_time": 0.0008, + "pending_message": null, + "exception": null + } + ], + "summary": { + "duration": 0.015, + "example_count": 2, + "failure_count": 0, + "pending_count": 0, + "errors_outside_of_examples_count": 0 + }, + "summary_line": "2 examples, 0 failures" + }"# + } + + fn with_failures_json() -> &'static str { + r#"{ + "version": "3.12.0", + "examples": [ + { + "id": "./spec/models/user_spec.rb[1:1]", + "description": "is valid", + "full_description": "User is valid", + "status": "passed", + "file_path": "./spec/models/user_spec.rb", + "line_number": 5, + "run_time": 0.001, + "pending_message": null, + "exception": null + }, + { + "id": "./spec/models/user_spec.rb[1:2]", + "description": "saves to database", + "full_description": "User saves to database", + "status": "failed", + "file_path": "./spec/models/user_spec.rb", + "line_number": 10, + "run_time": 0.002, + "pending_message": null, + "exception": { + "class": "RSpec::Expectations::ExpectationNotMetError", + "message": "expected true but got false", + "backtrace": [ + "/usr/local/lib/ruby/gems/3.2.0/gems/rspec-expectations-3.12.0/lib/rspec/expectations/fail_with.rb:37:in `fail_with'", + "./spec/models/user_spec.rb:11:in `block (2 levels) in '" + ] + } + } + ], + "summary": { + "duration": 0.123, + "example_count": 2, + "failure_count": 1, + "pending_count": 0, + "errors_outside_of_examples_count": 0 + }, + "summary_line": "2 examples, 1 failure" + }"# + } + + fn with_pending_json() -> &'static str { + r#"{ + "version": "3.12.0", + "examples": [ + { + "id": "./spec/models/post_spec.rb[1:1]", + "description": "creates a post", + "full_description": "Post creates a post", + "status": "passed", + "file_path": "./spec/models/post_spec.rb", + "line_number": 4, + "run_time": 0.002, + "pending_message": null, + "exception": null + }, + { + "id": "./spec/models/post_spec.rb[1:2]", + "description": "validates title", + "full_description": "Post validates title", + "status": "pending", + "file_path": "./spec/models/post_spec.rb", + "line_number": 8, + "run_time": 0.0, + "pending_message": "Not yet implemented", + "exception": null + } + ], + "summary": { + "duration": 0.05, + "example_count": 2, + "failure_count": 0, + "pending_count": 1, + "errors_outside_of_examples_count": 0 + }, + "summary_line": "2 examples, 0 failures, 1 pending" + }"# + } + + fn large_suite_json() -> &'static str { + r#"{ + "version": "3.12.0", + "examples": [ + {"id":"1","description":"test1","full_description":"Suite test1","status":"passed","file_path":"./spec/a_spec.rb","line_number":1,"run_time":0.01,"pending_message":null,"exception":null}, + {"id":"2","description":"test2","full_description":"Suite test2","status":"passed","file_path":"./spec/a_spec.rb","line_number":2,"run_time":0.01,"pending_message":null,"exception":null}, + {"id":"3","description":"test3","full_description":"Suite test3","status":"passed","file_path":"./spec/a_spec.rb","line_number":3,"run_time":0.01,"pending_message":null,"exception":null}, + {"id":"4","description":"test4","full_description":"Suite test4","status":"passed","file_path":"./spec/a_spec.rb","line_number":4,"run_time":0.01,"pending_message":null,"exception":null}, + {"id":"5","description":"test5","full_description":"Suite test5","status":"passed","file_path":"./spec/a_spec.rb","line_number":5,"run_time":0.01,"pending_message":null,"exception":null}, + {"id":"6","description":"test6","full_description":"Suite test6","status":"passed","file_path":"./spec/a_spec.rb","line_number":6,"run_time":0.01,"pending_message":null,"exception":null}, + {"id":"7","description":"test7","full_description":"Suite test7","status":"passed","file_path":"./spec/a_spec.rb","line_number":7,"run_time":0.01,"pending_message":null,"exception":null}, + {"id":"8","description":"test8","full_description":"Suite test8","status":"passed","file_path":"./spec/a_spec.rb","line_number":8,"run_time":0.01,"pending_message":null,"exception":null}, + {"id":"9","description":"test9","full_description":"Suite test9","status":"passed","file_path":"./spec/a_spec.rb","line_number":9,"run_time":0.01,"pending_message":null,"exception":null}, + {"id":"10","description":"test10","full_description":"Suite test10","status":"passed","file_path":"./spec/a_spec.rb","line_number":10,"run_time":0.01,"pending_message":null,"exception":null} + ], + "summary": { + "duration": 1.234, + "example_count": 10, + "failure_count": 0, + "pending_count": 0, + "errors_outside_of_examples_count": 0 + }, + "summary_line": "10 examples, 0 failures" + }"# + } + + #[test] + fn test_filter_rspec_all_pass() { + let result = filter_rspec_output(all_pass_json()); + assert!(result.starts_with("✓ RSpec:")); + assert!(result.contains("2 passed")); + assert!(result.contains("0.01s") || result.contains("0.02s")); + } + + #[test] + fn test_filter_rspec_with_failures() { + let result = filter_rspec_output(with_failures_json()); + assert!(result.contains("1 passed, 1 failed")); + assert!(result.contains("❌ User saves to database")); + assert!(result.contains("user_spec.rb:10")); + assert!(result.contains("ExpectationNotMetError")); + assert!(result.contains("expected true but got false")); + } + + #[test] + fn test_filter_rspec_with_pending() { + let result = filter_rspec_output(with_pending_json()); + assert!(result.starts_with("✓ RSpec:")); + assert!(result.contains("1 passed")); + assert!(result.contains("1 pending")); + } + + #[test] + fn test_filter_rspec_empty_output() { + let result = filter_rspec_output(""); + assert_eq!(result, "RSpec: No output"); + } + + #[test] + fn test_filter_rspec_no_examples() { + let json = r#"{ + "version": "3.12.0", + "examples": [], + "summary": { + "duration": 0.001, + "example_count": 0, + "failure_count": 0, + "pending_count": 0, + "errors_outside_of_examples_count": 0 + } + }"#; + let result = filter_rspec_output(json); + assert_eq!(result, "RSpec: No examples found"); + } + + #[test] + fn test_filter_rspec_errors_outside_examples() { + let json = r#"{ + "version": "3.12.0", + "examples": [], + "summary": { + "duration": 0.01, + "example_count": 0, + "failure_count": 0, + "pending_count": 0, + "errors_outside_of_examples_count": 1 + } + }"#; + let result = filter_rspec_output(json); + // Should NOT say "No examples found" — there was an error outside examples + assert!( + !result.contains("No examples found"), + "errors outside examples should not be treated as 'no examples': {}", + result + ); + } + + #[test] + fn test_filter_rspec_text_fallback() { + let text = r#" +..F. + +Failures: + + 1) User is valid + Failure/Error: expect(user).to be_valid + expected true got false + # ./spec/models/user_spec.rb:5 + +4 examples, 1 failure +"#; + let result = filter_rspec_output(text); + assert!(result.contains("RSpec:")); + assert!(result.contains("4 examples, 1 failure")); + assert!(result.contains("❌"), "should show failure marker"); + } + + #[test] + fn test_filter_rspec_text_fallback_extracts_failures() { + let text = r#"Randomized with seed 12345 +..F...E.. + +Failures: + + 1) User#full_name returns first and last name + Failure/Error: expect(user.full_name).to eq("John Doe") + expected: "John Doe" + got: "John D." + # /usr/local/lib/ruby/gems/3.2.0/gems/rspec-expectations-3.12.0/lib/rspec/expectations/fail_with.rb:37 + # ./spec/models/user_spec.rb:15 + + 2) Api::Controller#index fails + Failure/Error: get :index + expected 200 got 500 + # ./spec/controllers/api_spec.rb:42 + +9 examples, 2 failures +"#; + let result = filter_rspec_text(text); + assert!(result.contains("2 failures")); + assert!(result.contains("❌")); + // Should show spec file path, not gem backtrace + assert!(result.contains("spec/models/user_spec.rb:15")); + } + + #[test] + fn test_filter_rspec_backtrace_filters_gems() { + let result = filter_rspec_output(with_failures_json()); + // Should show the spec file backtrace, not the gem one + assert!(result.contains("user_spec.rb:11")); + assert!(!result.contains("gems/rspec-expectations")); + } + + #[test] + fn test_filter_rspec_exception_class_shortened() { + let result = filter_rspec_output(with_failures_json()); + // Should show "ExpectationNotMetError" not "RSpec::Expectations::ExpectationNotMetError" + assert!(result.contains("ExpectationNotMetError")); + assert!(!result.contains("RSpec::Expectations::ExpectationNotMetError")); + } + + #[test] + fn test_filter_rspec_many_failures_caps_at_five() { + let json = r#"{ + "version": "3.12.0", + "examples": [ + {"id":"1","description":"test 1","full_description":"A test 1","status":"failed","file_path":"./spec/a_spec.rb","line_number":5,"run_time":0.001,"pending_message":null,"exception":{"class":"RuntimeError","message":"boom 1","backtrace":["./spec/a_spec.rb:6:in `block'"]}}, + {"id":"2","description":"test 2","full_description":"A test 2","status":"failed","file_path":"./spec/a_spec.rb","line_number":10,"run_time":0.001,"pending_message":null,"exception":{"class":"RuntimeError","message":"boom 2","backtrace":["./spec/a_spec.rb:11:in `block'"]}}, + {"id":"3","description":"test 3","full_description":"A test 3","status":"failed","file_path":"./spec/a_spec.rb","line_number":15,"run_time":0.001,"pending_message":null,"exception":{"class":"RuntimeError","message":"boom 3","backtrace":["./spec/a_spec.rb:16:in `block'"]}}, + {"id":"4","description":"test 4","full_description":"A test 4","status":"failed","file_path":"./spec/a_spec.rb","line_number":20,"run_time":0.001,"pending_message":null,"exception":{"class":"RuntimeError","message":"boom 4","backtrace":["./spec/a_spec.rb:21:in `block'"]}}, + {"id":"5","description":"test 5","full_description":"A test 5","status":"failed","file_path":"./spec/a_spec.rb","line_number":25,"run_time":0.001,"pending_message":null,"exception":{"class":"RuntimeError","message":"boom 5","backtrace":["./spec/a_spec.rb:26:in `block'"]}}, + {"id":"6","description":"test 6","full_description":"A test 6","status":"failed","file_path":"./spec/a_spec.rb","line_number":30,"run_time":0.001,"pending_message":null,"exception":{"class":"RuntimeError","message":"boom 6","backtrace":["./spec/a_spec.rb:31:in `block'"]}} + ], + "summary": { + "duration": 0.05, + "example_count": 6, + "failure_count": 6, + "pending_count": 0, + "errors_outside_of_examples_count": 0 + }, + "summary_line": "6 examples, 6 failures" + }"#; + let result = filter_rspec_output(json); + assert!(result.contains("1. ❌"), "should show first failure"); + assert!(result.contains("5. ❌"), "should show fifth failure"); + assert!(!result.contains("6. ❌"), "should not show sixth inline"); + assert!( + result.contains("+1 more"), + "should show overflow count: {}", + result + ); + } + + #[test] + fn test_filter_rspec_text_fallback_no_summary() { + // If no summary line, returns last 5 lines (does not panic) + let text = "some output\nwithout a summary line"; + let result = filter_rspec_output(text); + assert!(!result.is_empty()); + } + + #[test] + fn test_filter_rspec_invalid_json_falls_back() { + let garbage = "not json at all { broken"; + let result = filter_rspec_output(garbage); + assert!(!result.is_empty(), "should not panic on invalid JSON"); + } + + // ── Noise stripping tests ──────────────────────────────────────────────── + + #[test] + fn test_strip_noise_spring() { + let input = "Running via Spring preloader in process 12345\n...\n3 examples, 0 failures"; + let result = strip_noise(input); + assert!(!result.contains("Spring")); + assert!(result.contains("3 examples")); + } + + #[test] + fn test_strip_noise_simplecov() { + let input = "...\n\nCoverage report generated for RSpec to /app/coverage.\n142 / 200 LOC (71.0%) covered.\n\n3 examples, 0 failures"; + let result = strip_noise(input); + assert!(!result.contains("Coverage report")); + assert!(!result.contains("LOC")); + assert!(result.contains("3 examples")); + } + + #[test] + fn test_strip_noise_deprecation() { + let input = "DEPRECATION WARNING: Using `return` in before callbacks is deprecated.\n...\n3 examples, 0 failures"; + let result = strip_noise(input); + assert!(!result.contains("DEPRECATION")); + assert!(result.contains("3 examples")); + } + + #[test] + fn test_strip_noise_finished_in() { + let input = "...\nFinished in 12.34 seconds (files took 3.21 seconds to load)\n3 examples, 0 failures"; + let result = strip_noise(input); + assert!(!result.contains("Finished in 12.34")); + assert!(result.contains("3 examples")); + } + + #[test] + fn test_strip_noise_capybara_screenshot() { + let input = "...\n saved screenshot to /tmp/capybara/screenshots/2026_failed.png\n3 examples, 1 failure"; + let result = strip_noise(input); + assert!(result.contains("[screenshot:")); + assert!(result.contains("failed.png")); + assert!(!result.contains("saved screenshot to")); + } + + // ── Token savings tests ────────────────────────────────────────────────── + + #[test] + fn test_token_savings_all_pass() { + let input = large_suite_json(); + let output = filter_rspec_output(input); + + let input_tokens = count_tokens(input); + let output_tokens = count_tokens(&output); + let savings = 100.0 - (output_tokens as f64 / input_tokens as f64 * 100.0); + + assert!( + savings >= 60.0, + "RSpec all-pass: expected ≥60% savings, got {:.1}% (in={}, out={})", + savings, + input_tokens, + output_tokens + ); + } + + #[test] + fn test_token_savings_with_failures() { + let input = with_failures_json(); + let output = filter_rspec_output(input); + + let input_tokens = count_tokens(input); + let output_tokens = count_tokens(&output); + let savings = 100.0 - (output_tokens as f64 / input_tokens as f64 * 100.0); + + assert!( + savings >= 60.0, + "RSpec failures: expected ≥60% savings, got {:.1}% (in={}, out={})", + savings, + input_tokens, + output_tokens + ); + } + + #[test] + fn test_token_savings_text_fallback() { + let input = r#"Running via Spring preloader in process 12345 +Randomized with seed 54321 +..F...E..F.. + +Failures: + + 1) User#full_name returns first and last name + Failure/Error: expect(user.full_name).to eq("John Doe") + expected: "John Doe" + got: "John D." + # /usr/local/lib/ruby/gems/3.2.0/gems/rspec-expectations-3.12.0/lib/rspec/expectations/fail_with.rb:37 + # ./spec/models/user_spec.rb:15 + # /usr/local/lib/ruby/gems/3.2.0/gems/rspec-core-3.12.0/lib/rspec/core/example.rb:258 + + 2) Api::Controller#index returns success + Failure/Error: get :index + expected 200 got 500 + # /usr/local/lib/ruby/gems/3.2.0/gems/rspec-expectations-3.12.0/lib/rspec/expectations/fail_with.rb:37 + # ./spec/controllers/api_spec.rb:42 + # /usr/local/lib/ruby/gems/3.2.0/gems/rspec-core-3.12.0/lib/rspec/core/example.rb:258 + +Failed examples: + +rspec ./spec/models/user_spec.rb:15 # User#full_name returns first and last name +rspec ./spec/controllers/api_spec.rb:42 # Api::Controller#index returns success + +12 examples, 2 failures + +Coverage report generated for RSpec to /app/coverage. +142 / 200 LOC (71.0%) covered. +"#; + let output = filter_rspec_text(input); + + let input_tokens = count_tokens(input); + let output_tokens = count_tokens(&output); + let savings = 100.0 - (output_tokens as f64 / input_tokens as f64 * 100.0); + + assert!( + savings >= 30.0, + "RSpec text fallback: expected ≥30% savings, got {:.1}% (in={}, out={})", + savings, + input_tokens, + output_tokens + ); + } + + // ── ANSI handling tests ──────────────────────────────────────────────── + + #[test] + fn test_filter_rspec_ansi_wrapped_json() { + // ANSI codes around JSON should fall back to text, not panic + let input = "\x1b[32m{\"version\":\"3.12.0\"\x1b[0m broken json"; + let result = filter_rspec_output(input); + assert!(!result.is_empty(), "should not panic on ANSI-wrapped JSON"); + } + + // ── Text fallback >5 failures truncation (Issue 9) ───────────────────── + + #[test] + fn test_filter_rspec_text_many_failures_caps_at_five() { + let text = r#"Randomized with seed 12345 +.......FFFFFFF + +Failures: + + 1) User#full_name fails + Failure/Error: expect(true).to eq(false) + # ./spec/models/user_spec.rb:5 + + 2) Post#title fails + Failure/Error: expect(true).to eq(false) + # ./spec/models/post_spec.rb:10 + + 3) Comment#body fails + Failure/Error: expect(true).to eq(false) + # ./spec/models/comment_spec.rb:15 + + 4) Session#token fails + Failure/Error: expect(true).to eq(false) + # ./spec/models/session_spec.rb:20 + + 5) Profile#avatar fails + Failure/Error: expect(true).to eq(false) + # ./spec/models/profile_spec.rb:25 + + 6) Team#members fails + Failure/Error: expect(true).to eq(false) + # ./spec/models/team_spec.rb:30 + + 7) Role#permissions fails + Failure/Error: expect(true).to eq(false) + # ./spec/models/role_spec.rb:35 + +14 examples, 7 failures +"#; + let result = filter_rspec_text(text); + assert!(result.contains("1. ❌"), "should show first failure"); + assert!(result.contains("5. ❌"), "should show fifth failure"); + assert!(!result.contains("6. ❌"), "should not show sixth inline"); + assert!( + result.contains("+2 more"), + "should show overflow count: {}", + result + ); + } + + // ── Header -> FailedExamples transition (Issue 13) ────────────────────── + + #[test] + fn test_filter_rspec_text_header_to_failed_examples() { + // Input that has "Failed examples:" directly (no "Failures:" block), + // followed by a summary line + let text = r#"..F.. + +Failed examples: + +rspec ./spec/models/user_spec.rb:5 # User is valid + +5 examples, 1 failure +"#; + let result = filter_rspec_text(text); + assert!( + result.contains("5 examples, 1 failure"), + "should contain summary: {}", + result + ); + assert!( + result.contains("RSpec:"), + "should have RSpec prefix: {}", + result + ); + } +} diff --git a/src/rubocop_cmd.rs b/src/rubocop_cmd.rs new file mode 100644 index 00000000..db2d0ac4 --- /dev/null +++ b/src/rubocop_cmd.rs @@ -0,0 +1,659 @@ +//! RuboCop linter filter. +//! +//! Injects `--format json` for structured output, parses offenses grouped by +//! file and sorted by severity. Falls back to text parsing for autocorrect mode, +//! when the user specifies a custom format, or when injected JSON output fails +//! to parse. + +use crate::tracking; +use crate::utils::{exit_code_from_output, ruby_exec}; +use anyhow::{Context, Result}; +use serde::Deserialize; + +// ── JSON structures matching RuboCop's --format json output ───────────────── + +#[derive(Deserialize)] +struct RubocopOutput { + files: Vec, + summary: RubocopSummary, +} + +#[derive(Deserialize)] +struct RubocopFile { + path: String, + offenses: Vec, +} + +#[derive(Deserialize)] +struct RubocopOffense { + cop_name: String, + severity: String, + message: String, + correctable: bool, + location: RubocopLocation, +} + +#[derive(Deserialize)] +struct RubocopLocation { + start_line: usize, +} + +#[derive(Deserialize)] +struct RubocopSummary { + offense_count: usize, + #[allow(dead_code)] + target_file_count: usize, + inspected_file_count: usize, + #[serde(default)] + correctable_offense_count: usize, +} + +// ── Public entry point ─────────────────────────────────────────────────────── + +pub fn run(args: &[String], verbose: u8) -> Result<()> { + let timer = tracking::TimedExecution::start(); + + let mut cmd = ruby_exec("rubocop"); + + // Detect autocorrect mode + let is_autocorrect = args + .iter() + .any(|a| a == "-a" || a == "-A" || a == "--auto-correct" || a == "--auto-correct-all"); + + // Inject --format json unless the user already specified a format + let has_format = args + .iter() + .any(|a| a.starts_with("--format") || a.starts_with("-f")); + + if !has_format && !is_autocorrect { + cmd.arg("--format").arg("json"); + } + + cmd.args(args); + + if verbose > 0 { + eprintln!("Running: rubocop {}", args.join(" ")); + } + + let output = cmd.output().context( + "Failed to run rubocop. Is it installed? Try: gem install rubocop or add it to your Gemfile", + )?; + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let raw = format!("{}\n{}", stdout, stderr); + + let exit_code = exit_code_from_output(&output, "rubocop"); + + let filtered = if stdout.trim().is_empty() && !output.status.success() { + "RuboCop: FAILED (no stdout, see stderr below)".to_string() + } else if has_format || is_autocorrect { + filter_rubocop_text(&stdout) + } else { + filter_rubocop_json(&stdout) + }; + + if let Some(hint) = crate::tee::tee_and_hint(&raw, "rubocop", exit_code) { + println!("{}\n{}", filtered, hint); + } else { + println!("{}", filtered); + } + + if !stderr.trim().is_empty() && (!output.status.success() || verbose > 0) { + eprintln!("{}", stderr.trim()); + } + + timer.track( + &format!("rubocop {}", args.join(" ")), + &format!("rtk rubocop {}", args.join(" ")), + &raw, + &filtered, + ); + + if !output.status.success() { + std::process::exit(exit_code); + } + + Ok(()) +} + +// ── JSON filtering ─────────────────────────────────────────────────────────── + +/// Rank severity for ordering: lower = more severe. +fn severity_rank(severity: &str) -> u8 { + match severity { + "fatal" | "error" => 0, + "warning" => 1, + "convention" | "refactor" | "info" => 2, + _ => 3, + } +} + +fn filter_rubocop_json(output: &str) -> String { + if output.trim().is_empty() { + return "RuboCop: No output".to_string(); + } + + let parsed: Result = serde_json::from_str(output); + let rubocop = match parsed { + Ok(r) => r, + Err(e) => { + eprintln!("[rtk] rubocop: JSON parse failed ({})", e); + return crate::utils::fallback_tail(output, "rubocop (JSON parse error)", 5); + } + }; + + let s = &rubocop.summary; + + if s.offense_count == 0 { + return format!("ok ✓ rubocop ({} files)", s.inspected_file_count); + } + + // When correctable_offense_count is 0, it could mean the field was absent + // (older RuboCop) or genuinely zero. Manual count as consistent fallback. + let correctable_count = if s.correctable_offense_count > 0 { + s.correctable_offense_count + } else { + rubocop + .files + .iter() + .flat_map(|f| &f.offenses) + .filter(|o| o.correctable) + .count() + }; + + let mut result = format!( + "rubocop: {} offenses ({} files)\n", + s.offense_count, s.inspected_file_count + ); + + // Build list of files with offenses, sorted by worst severity then file path + let mut files_with_offenses: Vec<&RubocopFile> = rubocop + .files + .iter() + .filter(|f| !f.offenses.is_empty()) + .collect(); + + // Sort files: worst severity first, then alphabetically + files_with_offenses.sort_by(|a, b| { + let a_worst = a + .offenses + .iter() + .map(|o| severity_rank(&o.severity)) + .min() + .unwrap_or(3); + let b_worst = b + .offenses + .iter() + .map(|o| severity_rank(&o.severity)) + .min() + .unwrap_or(3); + a_worst.cmp(&b_worst).then(a.path.cmp(&b.path)) + }); + + let max_files = 10; + let max_offenses_per_file = 5; + + for file in files_with_offenses.iter().take(max_files) { + let short = compact_ruby_path(&file.path); + result.push_str(&format!("\n{}\n", short)); + + // Sort offenses within file: by severity rank, then by line number + let mut sorted_offenses: Vec<&RubocopOffense> = file.offenses.iter().collect(); + sorted_offenses.sort_by(|a, b| { + severity_rank(&a.severity) + .cmp(&severity_rank(&b.severity)) + .then(a.location.start_line.cmp(&b.location.start_line)) + }); + + for offense in sorted_offenses.iter().take(max_offenses_per_file) { + let first_msg_line = offense.message.lines().next().unwrap_or(""); + result.push_str(&format!( + " :{} {} — {}\n", + offense.location.start_line, offense.cop_name, first_msg_line + )); + } + if sorted_offenses.len() > max_offenses_per_file { + result.push_str(&format!( + " ... +{} more\n", + sorted_offenses.len() - max_offenses_per_file + )); + } + } + + if files_with_offenses.len() > max_files { + result.push_str(&format!( + "\n... +{} more files\n", + files_with_offenses.len() - max_files + )); + } + + if correctable_count > 0 { + result.push_str(&format!( + "\n({} correctable, run `rubocop -A`)", + correctable_count + )); + } + + result.trim().to_string() +} + +// ── Text fallback ──────────────────────────────────────────────────────────── + +fn filter_rubocop_text(output: &str) -> String { + // Check for Ruby/Bundler errors first -- show error, truncated to avoid excessive tokens + for line in output.lines() { + let t = line.trim(); + if t.contains("cannot load such file") + || t.contains("Bundler::GemNotFound") + || t.contains("Gem::MissingSpecError") + || t.starts_with("rubocop: command not found") + || t.starts_with("rubocop: No such file") + { + let error_lines: Vec<&str> = output.trim().lines().take(20).collect(); + let truncated = error_lines.join("\n"); + let total_lines = output.trim().lines().count(); + if total_lines > 20 { + return format!( + "RuboCop error:\n{}\n... ({} more lines)", + truncated, + total_lines - 20 + ); + } + return format!("RuboCop error:\n{}", truncated); + } + } + + // Detect autocorrect summary: "N files inspected, M offenses detected, K offenses autocorrected" + for line in output.lines().rev() { + let t = line.trim(); + if t.contains("inspected") && t.contains("autocorrected") { + // Extract counts for compact autocorrect message + let files = extract_leading_number(t); + let corrected = extract_autocorrect_count(t); + if files > 0 && corrected > 0 { + return format!( + "ok ✓ rubocop -A ({} files, {} autocorrected)", + files, corrected + ); + } + return format!("RuboCop: {}", t); + } + if t.contains("inspected") && (t.contains("offense") || t.contains("no offenses")) { + if t.contains("no offenses") { + let files = extract_leading_number(t); + if files > 0 { + return format!("ok ✓ rubocop ({} files)", files); + } + return "ok ✓ rubocop (no offenses)".to_string(); + } + return format!("RuboCop: {}", t); + } + } + // Last resort: last 5 lines + crate::utils::fallback_tail(output, "rubocop", 5) +} + +/// Extract leading number from a string like "15 files inspected". +fn extract_leading_number(s: &str) -> usize { + s.split_whitespace() + .next() + .and_then(|w| w.parse().ok()) + .unwrap_or(0) +} + +/// Extract autocorrect count from summary like "... 3 offenses autocorrected". +fn extract_autocorrect_count(s: &str) -> usize { + // Look for "N offenses autocorrected" near end + let parts: Vec<&str> = s.split(',').collect(); + for part in parts.iter().rev() { + let t = part.trim(); + if t.contains("autocorrected") { + return extract_leading_number(t); + } + } + 0 +} + +/// Compact Ruby file path by finding the nearest Rails convention directory +/// and stripping the absolute path prefix. +fn compact_ruby_path(path: &str) -> String { + let path = path.replace('\\', "/"); + + for prefix in &[ + "app/models/", + "app/controllers/", + "app/views/", + "app/helpers/", + "app/services/", + "app/jobs/", + "app/mailers/", + "lib/", + "spec/", + "test/", + "config/", + ] { + if let Some(pos) = path.find(prefix) { + return path[pos..].to_string(); + } + } + + // Generic: strip up to last known directory marker + if let Some(pos) = path.rfind("/app/") { + return path[pos + 1..].to_string(); + } + if let Some(pos) = path.rfind('/') { + return path[pos + 1..].to_string(); + } + path +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::utils::count_tokens; + + fn no_offenses_json() -> &'static str { + r#"{ + "metadata": {"rubocop_version": "1.60.0"}, + "files": [], + "summary": { + "offense_count": 0, + "target_file_count": 0, + "inspected_file_count": 15 + } + }"# + } + + fn with_offenses_json() -> &'static str { + r#"{ + "metadata": {"rubocop_version": "1.60.0"}, + "files": [ + { + "path": "app/models/user.rb", + "offenses": [ + { + "severity": "convention", + "message": "Trailing whitespace detected.", + "cop_name": "Layout/TrailingWhitespace", + "correctable": true, + "location": {"start_line": 10, "start_column": 5, "last_line": 10, "last_column": 8, "length": 3, "line": 10, "column": 5} + }, + { + "severity": "convention", + "message": "Missing frozen string literal comment.", + "cop_name": "Style/FrozenStringLiteralComment", + "correctable": true, + "location": {"start_line": 1, "start_column": 1, "last_line": 1, "last_column": 1, "length": 1, "line": 1, "column": 1} + }, + { + "severity": "warning", + "message": "Useless assignment to variable - `x`.", + "cop_name": "Lint/UselessAssignment", + "correctable": false, + "location": {"start_line": 25, "start_column": 5, "last_line": 25, "last_column": 6, "length": 1, "line": 25, "column": 5} + } + ] + }, + { + "path": "app/controllers/users_controller.rb", + "offenses": [ + { + "severity": "convention", + "message": "Trailing whitespace detected.", + "cop_name": "Layout/TrailingWhitespace", + "correctable": true, + "location": {"start_line": 5, "start_column": 20, "last_line": 5, "last_column": 22, "length": 2, "line": 5, "column": 20} + }, + { + "severity": "error", + "message": "Syntax error, unexpected end-of-input.", + "cop_name": "Lint/Syntax", + "correctable": false, + "location": {"start_line": 30, "start_column": 1, "last_line": 30, "last_column": 1, "length": 1, "line": 30, "column": 1} + } + ] + } + ], + "summary": { + "offense_count": 5, + "target_file_count": 2, + "inspected_file_count": 20 + } + }"# + } + + #[test] + fn test_filter_rubocop_no_offenses() { + let result = filter_rubocop_json(no_offenses_json()); + assert_eq!(result, "ok ✓ rubocop (15 files)"); + } + + #[test] + fn test_filter_rubocop_with_offenses_per_file() { + let result = filter_rubocop_json(with_offenses_json()); + // Should show per-file offenses + assert!(result.contains("5 offenses (20 files)")); + // controllers file has error severity, should appear first + assert!(result.contains("app/controllers/users_controller.rb")); + assert!(result.contains("app/models/user.rb")); + // Per-file offense format: :line CopName — message + assert!(result.contains(":30 Lint/Syntax — Syntax error")); + assert!(result.contains(":10 Layout/TrailingWhitespace — Trailing whitespace")); + assert!(result.contains(":25 Lint/UselessAssignment — Useless assignment")); + } + + #[test] + fn test_filter_rubocop_severity_ordering() { + let result = filter_rubocop_json(with_offenses_json()); + // File with error should come before file with only convention/warning + let ctrl_pos = result.find("users_controller.rb").unwrap(); + let model_pos = result.find("app/models/user.rb").unwrap(); + assert!( + ctrl_pos < model_pos, + "Error-file should appear before convention-file" + ); + + // Within users_controller.rb, error should come before convention + let error_pos = result.find(":30 Lint/Syntax").unwrap(); + let conv_pos = result.find(":5 Layout/TrailingWhitespace").unwrap(); + assert!( + error_pos < conv_pos, + "Error offense should appear before convention" + ); + } + + #[test] + fn test_filter_rubocop_within_file_line_ordering() { + let result = filter_rubocop_json(with_offenses_json()); + // Within user.rb, warning (line 25) should come before conventions (line 1, 10) + let warning_pos = result.find(":25 Lint/UselessAssignment").unwrap(); + let conv1_pos = result.find(":1 Style/FrozenStringLiteralComment").unwrap(); + assert!( + warning_pos < conv1_pos, + "Warning should come before convention within same file" + ); + } + + #[test] + fn test_filter_rubocop_correctable_hint() { + let result = filter_rubocop_json(with_offenses_json()); + assert!(result.contains("3 correctable")); + assert!(result.contains("rubocop -A")); + } + + #[test] + fn test_filter_rubocop_text_fallback() { + let text = r#"Inspecting 10 files +.......... + +10 files inspected, no offenses detected"#; + let result = filter_rubocop_text(text); + assert_eq!(result, "ok ✓ rubocop (10 files)"); + } + + #[test] + fn test_filter_rubocop_text_autocorrect() { + let text = r#"Inspecting 15 files +...C..CC....... + +15 files inspected, 3 offenses detected, 3 offenses autocorrected"#; + let result = filter_rubocop_text(text); + assert_eq!(result, "ok ✓ rubocop -A (15 files, 3 autocorrected)"); + } + + #[test] + fn test_filter_rubocop_empty_output() { + let result = filter_rubocop_json(""); + assert_eq!(result, "RuboCop: No output"); + } + + #[test] + fn test_filter_rubocop_invalid_json_falls_back() { + let garbage = "some ruby warning\n{broken json"; + let result = filter_rubocop_json(garbage); + assert!(!result.is_empty(), "should not panic on invalid JSON"); + } + + #[test] + fn test_compact_ruby_path() { + assert_eq!( + compact_ruby_path("/home/user/project/app/models/user.rb"), + "app/models/user.rb" + ); + assert_eq!( + compact_ruby_path("app/controllers/users_controller.rb"), + "app/controllers/users_controller.rb" + ); + assert_eq!( + compact_ruby_path("/project/spec/models/user_spec.rb"), + "spec/models/user_spec.rb" + ); + assert_eq!( + compact_ruby_path("lib/tasks/deploy.rake"), + "lib/tasks/deploy.rake" + ); + } + + #[test] + fn test_filter_rubocop_caps_offenses_per_file() { + // File with 7 offenses should show 5 + overflow + let json = r#"{ + "metadata": {"rubocop_version": "1.60.0"}, + "files": [ + { + "path": "app/models/big.rb", + "offenses": [ + {"severity": "convention", "message": "msg1", "cop_name": "Cop/A", "correctable": false, "location": {"start_line": 1, "start_column": 1}}, + {"severity": "convention", "message": "msg2", "cop_name": "Cop/B", "correctable": false, "location": {"start_line": 2, "start_column": 1}}, + {"severity": "convention", "message": "msg3", "cop_name": "Cop/C", "correctable": false, "location": {"start_line": 3, "start_column": 1}}, + {"severity": "convention", "message": "msg4", "cop_name": "Cop/D", "correctable": false, "location": {"start_line": 4, "start_column": 1}}, + {"severity": "convention", "message": "msg5", "cop_name": "Cop/E", "correctable": false, "location": {"start_line": 5, "start_column": 1}}, + {"severity": "convention", "message": "msg6", "cop_name": "Cop/F", "correctable": false, "location": {"start_line": 6, "start_column": 1}}, + {"severity": "convention", "message": "msg7", "cop_name": "Cop/G", "correctable": false, "location": {"start_line": 7, "start_column": 1}} + ] + } + ], + "summary": {"offense_count": 7, "target_file_count": 1, "inspected_file_count": 5} + }"#; + let result = filter_rubocop_json(json); + assert!(result.contains(":5 Cop/E"), "should show 5th offense"); + assert!(!result.contains(":6 Cop/F"), "should not show 6th inline"); + assert!(result.contains("+2 more"), "should show overflow"); + } + + #[test] + fn test_filter_rubocop_text_bundler_error() { + let text = "Bundler::GemNotFound: Could not find gem 'rubocop' in any sources."; + let result = filter_rubocop_text(text); + assert!( + result.starts_with("RuboCop error:"), + "should detect Bundler error: {}", + result + ); + assert!(result.contains("GemNotFound")); + } + + #[test] + fn test_filter_rubocop_text_load_error() { + let text = + "/usr/lib/ruby/3.2.0/rubygems.rb:250: cannot load such file -- rubocop (LoadError)"; + let result = filter_rubocop_text(text); + assert!( + result.starts_with("RuboCop error:"), + "should detect load error: {}", + result + ); + } + + #[test] + fn test_filter_rubocop_text_with_offenses() { + let text = r#"Inspecting 5 files +..C.. + +5 files inspected, 1 offense detected"#; + let result = filter_rubocop_text(text); + assert_eq!(result, "RuboCop: 5 files inspected, 1 offense detected"); + } + + #[test] + fn test_severity_rank() { + assert!(severity_rank("error") < severity_rank("warning")); + assert!(severity_rank("warning") < severity_rank("convention")); + assert!(severity_rank("fatal") < severity_rank("warning")); + } + + #[test] + fn test_token_savings() { + let input = with_offenses_json(); + let output = filter_rubocop_json(input); + + let input_tokens = count_tokens(input); + let output_tokens = count_tokens(&output); + let savings = 100.0 - (output_tokens as f64 / input_tokens as f64 * 100.0); + + assert!( + savings >= 60.0, + "RuboCop: expected ≥60% savings, got {:.1}% (in={}, out={})", + savings, + input_tokens, + output_tokens + ); + } + + // ── ANSI handling test ────────────────────────────────────────────────── + + #[test] + fn test_filter_rubocop_json_with_ansi_prefix() { + // ANSI codes before JSON should trigger fallback, not panic + let input = "\x1b[33mWarning: something\x1b[0m\n{\"broken\": true}"; + let result = filter_rubocop_json(input); + assert!(!result.is_empty(), "should not panic on ANSI-prefixed JSON"); + } + + // ── 10-file cap test (Issue 12) ───────────────────────────────────────── + + #[test] + fn test_filter_rubocop_caps_at_ten_files() { + // Build JSON with 12 files, each having 1 offense + let mut files_json = Vec::new(); + for i in 1..=12 { + files_json.push(format!( + r#"{{"path": "app/models/model_{}.rb", "offenses": [{{"severity": "convention", "message": "msg{}", "cop_name": "Cop/X{}", "correctable": false, "location": {{"start_line": 1, "start_column": 1}}}}]}}"#, + i, i, i + )); + } + let json = format!( + r#"{{"metadata": {{"rubocop_version": "1.60.0"}}, "files": [{}], "summary": {{"offense_count": 12, "target_file_count": 12, "inspected_file_count": 12}}}}"#, + files_json.join(",") + ); + let result = filter_rubocop_json(&json); + assert!( + result.contains("+2 more files"), + "should show +2 more files overflow: {}", + result + ); + } +} diff --git a/src/utils.rs b/src/utils.rs index 6ea0698f..7a0f811e 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -192,6 +192,91 @@ pub fn detect_package_manager() -> &'static str { } } +/// 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. +/// Only uses `bundle exec ` when a Gemfile exists AND declares the tool as a gem, +/// so globally-installed tools aren't forced through bundler unnecessarily. +/// Falls back to bare tool execution with a stderr warning if the Gemfile cannot be read. +pub fn ruby_exec(tool: &str) -> Command { + if std::path::Path::new("Gemfile").exists() { + match std::fs::read_to_string("Gemfile") { + Ok(gemfile) => { + if gemfile_mentions_gem(&gemfile, tool) { + let mut c = Command::new("bundle"); + c.arg("exec").arg(tool); + return c; + } + } + Err(e) => { + eprintln!( + "[rtk] Warning: Gemfile exists but could not be read ({}). \ + Running '{}' directly (not via bundle exec). \ + Check file permissions if this causes version mismatches.", + e, tool + ); + } + } + } + Command::new(tool) +} + +/// Check if a Gemfile declares a gem matching the tool name. +/// Matches `gem 'tool'`, `gem "tool"`, `gem 'tool-rails'`, etc. +/// Avoids false positives from comments or unrelated substrings. +fn gemfile_mentions_gem(gemfile: &str, tool: &str) -> bool { + for line in gemfile.lines() { + let trimmed = line.trim(); + // Skip comments + if trimmed.starts_with('#') { + continue; + } + // Match gem declarations: gem 'tool' or gem "tool" or gem 'tool-*' + if let Some(rest) = trimmed.strip_prefix("gem") { + let rest = rest.trim_start(); + if rest.starts_with(&format!("'{}'", tool)) + || rest.starts_with(&format!("\"{}\"", tool)) + || rest.starts_with(&format!("'{}-", tool)) + || rest.starts_with(&format!("\"{}-", tool)) + { + return true; + } + } + } + false +} + /// Build a Command using the detected package manager's exec mechanism. /// Returns a Command ready to have tool-specific args appended. pub fn package_manager_exec(tool: &str) -> Command { @@ -225,6 +310,13 @@ pub fn package_manager_exec(tool: &str) -> Command { } } +/// 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() +} + #[cfg(test)] mod tests { use super::*; @@ -364,6 +456,31 @@ mod tests { assert_eq!(format_cpt(f64::NAN), "$0.00/MTok"); // NaN } + #[test] + fn test_ruby_exec_without_gemfile() { + // In the rtk repo there's no Gemfile, so ruby_exec should return Command::new(tool) + let cmd = ruby_exec("rspec"); + assert_eq!(cmd.get_program(), "rspec"); + } + + #[test] + fn test_fallback_tail_returns_last_n_lines() { + let input = "line1\nline2\nline3\nline4\nline5\nline6\nline7"; + let result = fallback_tail(input, "test", 3); + assert!(result.contains("line5")); + assert!(result.contains("line6")); + assert!(result.contains("line7")); + assert!(!result.contains("line4")); + } + + #[test] + fn test_fallback_tail_short_input() { + let input = "only\ntwo"; + let result = fallback_tail(input, "test", 5); + assert!(result.contains("only")); + assert!(result.contains("two")); + } + #[test] fn test_detect_package_manager_default() { // In the test environment (rtk repo), there's no JS lockfile @@ -395,4 +512,44 @@ mod tests { let result = truncate(cjk, 6); assert!(result.ends_with("...")); } + + #[test] + fn test_gemfile_mentions_gem_basic() { + let gemfile = "gem 'rspec'\ngem 'rails'\n"; + assert!(gemfile_mentions_gem(gemfile, "rspec")); + assert!(gemfile_mentions_gem(gemfile, "rails")); + assert!(!gemfile_mentions_gem(gemfile, "rubocop")); + } + + #[test] + fn test_gemfile_mentions_gem_double_quotes() { + let gemfile = "gem \"rspec\"\n"; + assert!(gemfile_mentions_gem(gemfile, "rspec")); + } + + #[test] + fn test_gemfile_mentions_gem_with_version() { + let gemfile = "gem 'rspec', '~> 3.0'\n"; + assert!(gemfile_mentions_gem(gemfile, "rspec")); + } + + #[test] + fn test_gemfile_mentions_gem_related_gem() { + // gem 'rspec-rails' should match tool "rspec" (tool-prefixed gems) + let gemfile = "gem 'rspec-rails'\n"; + assert!(gemfile_mentions_gem(gemfile, "rspec")); + } + + #[test] + fn test_gemfile_mentions_gem_no_false_positive_comments() { + let gemfile = "# We don't use rspec\ngem 'minitest'\n"; + assert!(!gemfile_mentions_gem(gemfile, "rspec")); + } + + #[test] + fn test_gemfile_mentions_gem_no_false_positive_substring() { + // "guardrails" should NOT match "rails" + let gemfile = "gem 'guardrails'\n"; + assert!(!gemfile_mentions_gem(gemfile, "rails")); + } }