From 542507749fb7576c76d0f0f190fd1ddac9eef536 Mon Sep 17 00:00:00 2001 From: Nicholas Lee Date: Sun, 15 Mar 2026 18:35:38 -0700 Subject: [PATCH 1/5] feat(ruby): add RSpec and RuboCop filters with shared Ruby utilities Port upstream PR #292 (rtk-ai/rtk feat/ruby-rails-support): - rspec_cmd.rs: RSpec JSON filter with text fallback (~1000 lines, 97 tests) - rubocop_cmd.rs: RuboCop JSON filter grouped by cop/severity (~660 lines) - utils.rs: ruby_exec(), fallback_tail(), exit_code_from_output(), count_tokens() ruby_exec() uses bundle exec whenever Gemfile exists (transitive deps like rake aren't declared in Gemfile but still need bundler) - E2E smoke test script (scripts/test-ruby.sh) Co-Authored-By: Claude Opus 4.6 Signed-off-by: Nicholas Lee --- scripts/test-ruby.sh | 386 ++++++++++++++++ src/rspec_cmd.rs | 1003 ++++++++++++++++++++++++++++++++++++++++++ src/rubocop_cmd.rs | 659 +++++++++++++++++++++++++++ src/utils.rs | 90 +++- 4 files changed, 2126 insertions(+), 12 deletions(-) create mode 100755 scripts/test-ruby.sh create mode 100644 src/rspec_cmd.rs create mode 100644 src/rubocop_cmd.rs diff --git a/scripts/test-ruby.sh b/scripts/test-ruby.sh new file mode 100755 index 000000000..e8e774228 --- /dev/null +++ b/scripts/test-ruby.sh @@ -0,0 +1,386 @@ +#!/usr/bin/env bash +# +# RTK Smoke Tests — Ruby (RSpec + RuboCop only) +# Creates a minimal Rails app, exercises RTK rspec/rubocop filters, then cleans up. +# Usage: bash scripts/test-ruby.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" +} + +# Assert command exits with non-zero and output matches needle +assert_exit_nonzero() { + local name="$1"; local needle="$2"; shift 2 + local output + local rc=0 + output=$("$@" 2>&1) || rc=$? + if [[ $rc -ne 0 ]] && echo "$output" | grep -qi "$needle"; then + PASS=$((PASS + 1)) + printf " ${GREEN}PASS${NC} %s (exit=%d)\n" "$name" "$rc" + else + FAIL=$((FAIL + 1)) + FAILURES+=("$name") + printf " ${RED}FAIL${NC} %s (exit=%d)\n" "$name" "$rc" + if [[ $rc -eq 0 ]]; then + printf " expected non-zero exit, got 0\n" + else + printf " expected: '%s'\n" "$needle" + fi + printf " out: %s\n" "$(echo "$output" | head -3)" + fi +} + +section() { + printf "\n${BOLD}${CYAN}── %s ──${NC}\n" "$1" +} + +# ── Prerequisite checks ───────────────────────────── + +RTK=$(command -v rtk || echo "") +if [[ -z "$RTK" ]]; then + echo "rtk not found in PATH. Run: cargo install --path ." + exit 1 +fi + +if ! command -v ruby >/dev/null 2>&1; then + echo "ruby not found in PATH. Install Ruby first." + exit 1 +fi + +if ! command -v bundle >/dev/null 2>&1; then + echo "bundler not found in PATH. Run: gem install bundler" + exit 1 +fi + +if ! command -v rails >/dev/null 2>&1; then + echo "rails not found in PATH. Run: gem install rails" + exit 1 +fi + +# ── Preamble ───────────────────────────────────────── + +printf "${BOLD}RTK Smoke Tests — Ruby (RSpec + RuboCop)${NC}\n" +printf "Binary: %s (%s)\n" "$RTK" "$(rtk --version)" +printf "Ruby: %s\n" "$(ruby --version)" +printf "Rails: %s\n" "$(rails --version)" +printf "Bundler: %s\n" "$(bundle --version)" +printf "Date: %s\n\n" "$(date '+%Y-%m-%d %H:%M')" + +# ── Temp dir + cleanup trap ────────────────────────── + +TMPDIR=$(mktemp -d /tmp/rtk-ruby-smoke-XXXXXX) +trap 'rm -rf "$TMPDIR"' EXIT + +printf "${BOLD}Setting up temporary Rails app in %s ...${NC}\n" "$TMPDIR" + +# ── Setup phase (not counted in assertions) ────────── + +cd "$TMPDIR" + +# 1. Create minimal Rails app +printf " → rails new (--minimal --skip-git --skip-docker) ...\n" +rails new rtk_smoke_app --minimal --skip-git --skip-docker --quiet 2>&1 | tail -1 || true +cd rtk_smoke_app + +# 2. Add rspec-rails and rubocop to Gemfile +cat >> Gemfile <<'GEMFILE' + +group :development, :test do + gem 'rspec-rails' + gem 'rubocop', require: false +end +GEMFILE + +# 3. Bundle install +printf " → bundle install ...\n" +bundle install --quiet 2>&1 | tail -1 || true + +# 4. Generate scaffold (creates model for specs) +printf " → rails generate scaffold Post ...\n" +rails generate scaffold Post title:string body:text published:boolean --quiet 2>&1 | tail -1 || true + +# 5. Install RSpec + create manual spec file +printf " → rails generate rspec:install ...\n" +rails generate rspec:install --quiet 2>&1 | tail -1 || true + +mkdir -p spec/models +cat > spec/models/post_spec.rb <<'SPEC' +require 'rails_helper' + +RSpec.describe Post, type: :model do + it "is valid with valid attributes" do + post = Post.new(title: "Test", body: "Body", published: false) + expect(post).to be_valid + end +end +SPEC + +# 6. Create + migrate database +printf " → rails db:create && db:migrate ...\n" +rails db:create --quiet 2>&1 | tail -1 || true +rails db:migrate --quiet 2>&1 | tail -1 || true + +# 7. Create a file with intentional RuboCop offenses +printf " → creating rubocop_bait.rb with intentional offenses ...\n" +cat > app/models/rubocop_bait.rb <<'BAIT' +class RubocopBait < ApplicationRecord + def messy_method() + x = 1 + y = 2 + if x == 1 + puts "hello world" + end + return nil + end +end +BAIT + +# 8. Create a failing RSpec spec +printf " → creating failing rspec spec ...\n" +cat > spec/models/post_fail_spec.rb <<'FAILSPEC' +require 'rails_helper' + +RSpec.describe Post, type: :model do + it "intentionally fails validation check" do + post = Post.new(title: "Hello", body: "World", published: false) + expect(post.title).to eq("Wrong Title On Purpose") + end +end +FAILSPEC + +# 9. Create an RSpec spec with pending example +printf " → creating rspec spec with pending example ...\n" +cat > spec/models/post_pending_spec.rb <<'PENDSPEC' +require 'rails_helper' + +RSpec.describe Post, type: :model do + it "is valid with title" do + post = Post.new(title: "OK", body: "Body", published: false) + expect(post).to be_valid + end + + it "will support markdown later" do + pending "Not yet implemented" + expect(Post.new.render_markdown).to eq("

hello

") + end +end +PENDSPEC + +printf "\n${BOLD}Setup complete. Running tests...${NC}\n" + +# ══════════════════════════════════════════════════════ +# Test sections +# ══════════════════════════════════════════════════════ + +# ── 1. rspec ──────────────────────────────────────── + +section "RSpec" + +assert_output "rtk rspec (with failure)" \ + "failed" \ + rtk rspec + +assert_output "rtk rspec spec/models/post_spec.rb (pass)" \ + "RSpec.*passed" \ + rtk rspec spec/models/post_spec.rb + +assert_output "rtk rspec spec/models/post_fail_spec.rb (fail)" \ + "failed\|❌" \ + rtk rspec spec/models/post_fail_spec.rb + +# ── 2. rubocop ────────────────────────────────────── + +section "RuboCop" + +assert_output "rtk rubocop (with offenses)" \ + "offense" \ + rtk rubocop + +assert_output "rtk rubocop app/ (with offenses)" \ + "rubocop_bait\|offense" \ + rtk rubocop app/ + +# ── 3. Exit code preservation ──────────────────────── + +section "Exit code preservation" + +assert_exit_nonzero "rtk rspec exits non-zero on failure" \ + "failed\|failure" \ + rtk rspec spec/models/post_fail_spec.rb + +assert_exit_nonzero "rtk rubocop exits non-zero on offenses" \ + "offense" \ + rtk rubocop app/models/rubocop_bait.rb + +# ── 4. bundle exec variants ───────────────────────── + +section "bundle exec variants" + +assert_output "bundle exec rspec spec/models/post_spec.rb" \ + "passed\|example" \ + rtk bundle exec rspec spec/models/post_spec.rb + +assert_output "bundle exec rubocop app/" \ + "offense" \ + rtk bundle exec rubocop app/ + +# ── 5. rubocop autocorrect ─────────────────────────── + +section "RuboCop autocorrect" + +# Copy bait file so autocorrect has something to fix +cp app/models/rubocop_bait.rb app/models/rubocop_bait_ac.rb +sed -i.bak 's/RubocopBait/RubocopBaitAc/' app/models/rubocop_bait_ac.rb + +assert_output "rtk rubocop -A (autocorrect)" \ + "autocorrected\|rubocop\|ok\|offense\|inspected" \ + rtk rubocop -A app/models/rubocop_bait_ac.rb + +# Clean up autocorrect test file +rm -f app/models/rubocop_bait_ac.rb app/models/rubocop_bait_ac.rb.bak + +# ── 6. rspec with pending ──────────────────────────── + +section "RSpec pending" + +assert_output "rtk rspec with pending example" \ + "pending" \ + rtk rspec spec/models/post_pending_spec.rb + +# ── 7. rspec --format documentation (text fallback) ─ + +section "RSpec text fallback" + +assert_output "rtk rspec --format documentation (text path)" \ + "valid\|example\|post" \ + rtk rspec --format documentation spec/models/post_spec.rb + +# ── 8. rspec empty suite (no matching specs) ───────── + +section "RSpec empty suite" + +assert_output "rtk rspec nonexistent tag" \ + "0 examples\|No examples" \ + rtk rspec --tag nonexistent spec/models/post_spec.rb + +# ── 9. Token savings ───────────────────────────────── + +section "Token savings" + +# rspec (passing spec) +raw_len=$( (bundle exec rspec spec/models/post_spec.rb 2>&1 || true) | wc -c | tr -d ' ') +rtk_len=$( (rtk rspec spec/models/post_spec.rb 2>&1 || true) | wc -c | tr -d ' ') +if [[ "$rtk_len" -lt "$raw_len" ]]; then + PASS=$((PASS + 1)) + printf " ${GREEN}PASS${NC} rspec: rtk (%s bytes) < raw (%s bytes)\n" "$rtk_len" "$raw_len" +else + FAIL=$((FAIL + 1)) + FAILURES+=("token savings: rspec") + printf " ${RED}FAIL${NC} rspec: rtk (%s bytes) >= raw (%s bytes)\n" "$rtk_len" "$raw_len" +fi + +# rubocop (exits non-zero on offenses, so || true) +raw_len=$( (bundle exec rubocop app/ 2>&1 || true) | wc -c | tr -d ' ') +rtk_len=$( (rtk rubocop app/ 2>&1 || true) | wc -c | tr -d ' ') +if [[ "$rtk_len" -lt "$raw_len" ]]; then + PASS=$((PASS + 1)) + printf " ${GREEN}PASS${NC} rubocop: rtk (%s bytes) < raw (%s bytes)\n" "$rtk_len" "$raw_len" +else + FAIL=$((FAIL + 1)) + FAILURES+=("token savings: rubocop") + printf " ${RED}FAIL${NC} rubocop: rtk (%s bytes) >= raw (%s bytes)\n" "$rtk_len" "$raw_len" +fi + +# ── 10. Verbose flag ───────────────────────────────── + +section "Verbose flag (-v)" + +assert_output "rtk -v rspec (verbose)" \ + "RSpec\|passed\|Running\|example" \ + rtk -v rspec spec/models/post_spec.rb + +# ══════════════════════════════════════════════════════ +# Report +# ══════════════════════════════════════════════════════ + +printf "\n${BOLD}══════════════════════════════════════${NC}\n" +printf "${BOLD}Results: ${GREEN}%d passed${NC}, ${RED}%d failed${NC}, ${YELLOW}%d skipped${NC}\n" "$PASS" "$FAIL" "$SKIP" + +if [[ ${#FAILURES[@]} -gt 0 ]]; then + printf "\n${RED}Failures:${NC}\n" + for f in "${FAILURES[@]}"; do + printf " - %s\n" "$f" + done +fi + +printf "${BOLD}══════════════════════════════════════${NC}\n" + +exit "$FAIL" diff --git a/src/rspec_cmd.rs b/src/rspec_cmd.rs new file mode 100644 index 000000000..7f45c0c38 --- /dev/null +++ b/src/rspec_cmd.rs @@ -0,0 +1,1003 @@ +//! RSpec test runner filter. +//! +//! Injects `--format json` to get structured output, parses it to show only +//! failures. Falls back to a state-machine text parser when JSON is unavailable +//! (e.g., user specified `--format documentation`) or when injected JSON output +//! fails to parse. + +use crate::tracking; +use crate::utils::{exit_code_from_output, fallback_tail, ruby_exec, truncate}; +use anyhow::{Context, Result}; +use lazy_static::lazy_static; +use regex::Regex; +use serde::Deserialize; + +// ── Noise-stripping regex patterns ────────────────────────────────────────── + +lazy_static! { + static ref RE_SPRING: Regex = Regex::new(r"(?i)running via spring preloader").unwrap(); + static ref RE_SIMPLECOV: Regex = + Regex::new(r"(?i)(coverage report|simplecov|coverage/|\.simplecov|All Files.*Lines)") + .unwrap(); + static ref RE_DEPRECATION: Regex = Regex::new(r"^DEPRECATION WARNING:").unwrap(); + static ref RE_FINISHED_IN: Regex = Regex::new(r"^Finished in \d").unwrap(); + static ref RE_SCREENSHOT: Regex = Regex::new(r"saved screenshot to (.+)").unwrap(); + static ref RE_RSPEC_SUMMARY: Regex = Regex::new(r"(\d+) examples?, (\d+) failures?").unwrap(); +} + +// ── JSON structures matching RSpec's --format json output ─────────────────── + +#[derive(Deserialize)] +struct RspecOutput { + examples: Vec, + summary: RspecSummary, +} + +#[derive(Deserialize)] +struct RspecExample { + full_description: String, + status: String, + file_path: String, + line_number: u32, + exception: Option, +} + +#[derive(Deserialize)] +struct RspecException { + class: String, + message: String, + #[serde(default)] + backtrace: Vec, +} + +#[derive(Deserialize)] +struct RspecSummary { + duration: f64, + example_count: usize, + failure_count: usize, + pending_count: usize, + #[serde(default)] + errors_outside_of_examples_count: usize, +} + +// ── Public entry point ─────────────────────────────────────────────────────── + +pub fn run(args: &[String], verbose: u8) -> Result<()> { + let timer = tracking::TimedExecution::start(); + + let mut cmd = ruby_exec("rspec"); + + // Inject --format json unless the user already specified a format + 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 000000000..db2d0ac4f --- /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 ff84961cc..e8939900e 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -227,6 +227,51 @@ 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. +/// Uses `bundle exec ` when a Gemfile exists (transitive deps like rake +/// won't appear in the Gemfile but still need bundler for version isolation). +pub fn ruby_exec(tool: &str) -> Command { + if std::path::Path::new("Gemfile").exists() { + let mut c = Command::new("bundle"); + c.arg("exec").arg(tool); + return c; + } + Command::new(tool) +} + /// 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 { @@ -316,6 +361,13 @@ pub fn tool_exists(name: &str) -> bool { which::which(name).is_ok() } +/// 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::*; @@ -491,7 +543,6 @@ mod tests { #[test] fn test_resolve_binary_finds_known_command() { - // "cargo" must be on PATH in any Rust dev environment let result = resolve_binary("cargo"); assert!( result.is_ok(), @@ -526,7 +577,6 @@ mod tests { .file_name() .expect("should have filename") .to_string_lossy(); - // On Windows this could be "cargo.exe", on Unix just "cargo" assert!( filename.starts_with("cargo"), "resolved path filename should start with 'cargo', got: {}", @@ -578,7 +628,6 @@ mod tests { use super::super::*; use std::fs; - /// Create a temporary .cmd wrapper to simulate Node.js tool installation fn create_temp_cmd_wrapper(dir: &std::path::Path, name: &str) -> std::path::PathBuf { let cmd_path = dir.join(format!("{}.cmd", name)); fs::write(&cmd_path, "@echo off\r\necho fake-tool-output\r\n") @@ -586,7 +635,6 @@ mod tests { cmd_path } - /// Build a PATH string that includes the temp dir fn path_with_dir(dir: &std::path::Path) -> std::ffi::OsString { let original = std::env::var_os("PATH").unwrap_or_default(); let mut new_path = std::ffi::OsString::from(dir.as_os_str()); @@ -600,7 +648,6 @@ mod tests { let temp_dir = tempfile::tempdir().expect("failed to create temp dir"); create_temp_cmd_wrapper(temp_dir.path(), "fake-tool-test"); - // Use which::which_in to avoid mutating global PATH (thread-safe) let search_path = path_with_dir(temp_dir.path()); let result = which::which_in( "fake-tool-test", @@ -653,7 +700,6 @@ mod tests { let temp_dir = tempfile::tempdir().expect("failed to create temp dir"); create_temp_cmd_wrapper(temp_dir.path(), "fake-exec-test"); - // Resolve the full path, then execute it directly (no PATH mutation) let search_path = path_with_dir(temp_dir.path()); let resolved = which::which_in( "fake-exec-test", @@ -679,13 +725,7 @@ mod tests { #[test] fn test_resolved_command_fallback_on_unknown_binary() { - // When resolve_binary fails, resolved_command should fall back to - // Command::new(name) instead of panicking. On Windows this also - // prints a warning to stderr. let mut cmd = resolved_command("nonexistent_binary_xyz_99999"); - // The Command should be created (not panic). Attempting to run it - // will fail, but that's expected — we just verify the fallback path - // produces a usable Command. let result = cmd.output(); assert!( result.is_err() || !result.unwrap().status.success(), @@ -711,4 +751,30 @@ mod tests { ); } } + + // ===== Ruby utilities tests ===== + + #[test] + fn test_ruby_exec_without_gemfile() { + 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")); + } } From a549036ab81dca595cb20f80e4edc0af22973ff5 Mon Sep 17 00:00:00 2001 From: Nicholas Lee Date: Sun, 15 Mar 2026 18:40:10 -0700 Subject: [PATCH 2/5] feat(ruby): add minitest filter for rake/rails test State machine parser for Minitest output (rake test / rails test): - Parses both standard Minitest and minitest-reporters formats - Handles "# Running:" and "Started with run options" headers - Summary matches both "N runs" and "N tests" keywords - All-pass: "ok rake test: N runs, 0 failures" - Failures: summary + numbered failure details (limit 10, truncated) - Handles ANSI codes, skipped tests, errors - 80%+ token savings on typical test runs (11 tests) Co-Authored-By: Claude Opus 4.6 Signed-off-by: Nicholas Lee --- src/rake_cmd.rs | 440 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 440 insertions(+) create mode 100644 src/rake_cmd.rs diff --git a/src/rake_cmd.rs b/src/rake_cmd.rs new file mode 100644 index 000000000..a68df8cf9 --- /dev/null +++ b/src/rake_cmd.rs @@ -0,0 +1,440 @@ +//! Minitest output filter for `rake test` and `rails test`. +//! +//! Parses the standard Minitest output format produced by both `rake test` and +//! `rails test`, filtering down to failures/errors and the summary line. +//! Uses `ruby_exec("rake")` to auto-detect `bundle exec`. + +use crate::tracking; +use crate::utils::{exit_code_from_output, ruby_exec, strip_ansi}; +use anyhow::{Context, Result}; + +pub fn run(args: &[String], verbose: u8) -> Result<()> { + let timer = tracking::TimedExecution::start(); + + let mut cmd = ruby_exec("rake"); + for arg in args { + cmd.arg(arg); + } + + if verbose > 0 { + eprintln!( + "Running: {} {}", + cmd.get_program().to_string_lossy(), + args.join(" ") + ); + } + + let output = cmd + .output() + .context("Failed to run rake. Is it installed? Try: gem install rake")?; + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let raw = format!("{}\n{}", stdout, stderr); + + let filtered = filter_minitest_output(&raw); + + let exit_code = exit_code_from_output(&output, "rake"); + if let Some(hint) = crate::tee::tee_and_hint(&raw, "rake", exit_code) { + println!("{}\n{}", filtered, hint); + } else { + println!("{}", filtered); + } + + if !stderr.trim().is_empty() && verbose > 0 { + eprintln!("{}", stderr.trim()); + } + + timer.track( + &format!("rake {}", args.join(" ")), + &format!("rtk rake {}", args.join(" ")), + &raw, + &filtered, + ); + + if !output.status.success() { + std::process::exit(exit_code); + } + + Ok(()) +} + +#[derive(Debug, PartialEq)] +enum ParseState { + Header, + Running, + Failures, + Summary, +} + +/// Parse Minitest output using a state machine. +/// +/// Minitest produces output like: +/// ```text +/// Run options: --seed 12345 +/// +/// # Running: +/// +/// ..F..E.. +/// +/// Finished in 0.123456s, 64.8 runs/s +/// +/// 1) Failure: +/// TestSomething#test_that_fails [/path/to/test.rb:15]: +/// Expected: true +/// Actual: false +/// +/// 8 runs, 7 assertions, 1 failures, 1 errors, 0 skips +/// ``` +fn filter_minitest_output(output: &str) -> String { + let clean = strip_ansi(output); + let mut state = ParseState::Header; + let mut failures: Vec = Vec::new(); + let mut current_failure: Vec = Vec::new(); + let mut summary_line = String::new(); + + for line in clean.lines() { + let trimmed = line.trim(); + + // Detect summary line anywhere (it's always last meaningful line) + // Handles both "N runs, N assertions, ..." and "N tests, N assertions, ..." + if (trimmed.contains(" runs,") || trimmed.contains(" tests,")) + && trimmed.contains(" assertions,") + { + summary_line = trimmed.to_string(); + continue; + } + + // State transitions — handle both standard Minitest and minitest-reporters + if trimmed == "# Running:" || trimmed.starts_with("Started with run options") { + state = ParseState::Running; + continue; + } + + if trimmed.starts_with("Finished in ") { + state = ParseState::Failures; + continue; + } + + match state { + ParseState::Header | ParseState::Running => { + // Skip seed line, blank lines, progress dots + continue; + } + ParseState::Failures => { + if is_failure_header(trimmed) { + if !current_failure.is_empty() { + failures.push(current_failure.join("\n")); + current_failure.clear(); + } + current_failure.push(trimmed.to_string()); + } else if trimmed.is_empty() && !current_failure.is_empty() { + failures.push(current_failure.join("\n")); + current_failure.clear(); + } else if !trimmed.is_empty() { + current_failure.push(line.to_string()); + } + } + ParseState::Summary => {} + } + } + + // Save last failure if any + if !current_failure.is_empty() { + failures.push(current_failure.join("\n")); + } + + build_minitest_summary(&summary_line, &failures) +} + +fn is_failure_header(line: &str) -> bool { + lazy_static::lazy_static! { + static ref RE_FAILURE: regex::Regex = + regex::Regex::new(r"^\d+\)\s+(Failure|Error):$").unwrap(); + } + RE_FAILURE.is_match(line) +} + +fn build_minitest_summary(summary: &str, failures: &[String]) -> String { + let (runs, _assertions, fail_count, error_count, skips) = parse_minitest_summary(summary); + + if runs == 0 && summary.is_empty() { + return "rake test: no tests ran".to_string(); + } + + if fail_count == 0 && error_count == 0 { + let mut msg = format!("ok rake test: {} runs, 0 failures", runs); + if skips > 0 { + msg.push_str(&format!(", {} skips", skips)); + } + return msg; + } + + let mut result = String::new(); + result.push_str(&format!( + "rake test: {} runs, {} failures, {} errors", + runs, fail_count, error_count + )); + if skips > 0 { + result.push_str(&format!(", {} skips", skips)); + } + result.push('\n'); + + if failures.is_empty() { + return result.trim().to_string(); + } + + result.push('\n'); + + for (i, failure) in failures.iter().take(10).enumerate() { + let lines: Vec<&str> = failure.lines().collect(); + // First line is like " 1) Failure:" or " 1) Error:" + if let Some(header) = lines.first() { + result.push_str(&format!("{}. {}\n", i + 1, header.trim())); + } + // Remaining lines contain test name, file:line, assertion message + for line in lines.iter().skip(1).take(4) { + let trimmed = line.trim(); + if !trimmed.is_empty() { + result.push_str(&format!(" {}\n", crate::utils::truncate(trimmed, 120))); + } + } + if i < failures.len().min(10) - 1 { + result.push('\n'); + } + } + + if failures.len() > 10 { + result.push_str(&format!("\n... +{} more failures\n", failures.len() - 10)); + } + + result.trim().to_string() +} + +fn parse_minitest_summary(summary: &str) -> (usize, usize, usize, usize, usize) { + let mut runs = 0; + let mut assertions = 0; + let mut failures = 0; + let mut errors = 0; + let mut skips = 0; + + for part in summary.split(',') { + let part = part.trim(); + let words: Vec<&str> = part.split_whitespace().collect(); + if words.len() >= 2 { + if let Ok(n) = words[0].parse::() { + match words[1].trim_end_matches(',') { + "runs" | "run" | "tests" | "test" => runs = n, + "assertions" | "assertion" => assertions = n, + "failures" | "failure" => failures = n, + "errors" | "error" => errors = n, + "skips" | "skip" => skips = n, + _ => {} + } + } + } + } + + (runs, assertions, failures, errors, skips) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::utils::count_tokens; + + #[test] + fn test_filter_minitest_all_pass() { + let output = r#"Run options: --seed 12345 + +# Running: + +........ + +Finished in 0.123456s, 64.8 runs/s, 72.9 assertions/s. + +8 runs, 9 assertions, 0 failures, 0 errors, 0 skips"#; + + let result = filter_minitest_output(output); + assert!(result.contains("ok rake test")); + assert!(result.contains("8 runs")); + assert!(result.contains("0 failures")); + } + + #[test] + fn test_filter_minitest_with_failures() { + let output = r#"Run options: --seed 54321 + +# Running: + +..F.... + +Finished in 0.234567s, 29.8 runs/s + + 1) Failure: +TestSomething#test_that_fails [/path/to/test.rb:15]: +Expected: true + Actual: false + +7 runs, 7 assertions, 1 failures, 0 errors, 0 skips"#; + + let result = filter_minitest_output(output); + assert!(result.contains("1 failures")); + assert!(result.contains("test_that_fails")); + assert!(result.contains("Expected: true")); + } + + #[test] + fn test_filter_minitest_with_errors() { + let output = r#"Run options: --seed 99999 + +# Running: + +.E.... + +Finished in 0.345678s, 17.4 runs/s + + 1) Error: +TestOther#test_boom [/path/to/test.rb:42]: +RuntimeError: something went wrong + /path/to/test.rb:42:in `test_boom' + +6 runs, 5 assertions, 0 failures, 1 errors, 0 skips"#; + + let result = filter_minitest_output(output); + assert!(result.contains("1 errors")); + assert!(result.contains("test_boom")); + assert!(result.contains("RuntimeError")); + } + + #[test] + fn test_filter_minitest_empty() { + let result = filter_minitest_output(""); + assert!(result.contains("no tests ran")); + } + + #[test] + fn test_filter_minitest_skip() { + let output = r#"Run options: --seed 11111 + +# Running: + +..S.. + +Finished in 0.100000s, 50.0 runs/s + +5 runs, 4 assertions, 0 failures, 0 errors, 1 skips"#; + + let result = filter_minitest_output(output); + assert!(result.contains("ok rake test")); + assert!(result.contains("1 skips")); + } + + #[test] + fn test_token_savings() { + let mut dots = String::new(); + for _ in 0..20 { + dots.push_str( + "......................................................................\n", + ); + } + let output = format!( + "Run options: --seed 12345\n\n\ + # Running:\n\n\ + {}\n\ + Finished in 2.345678s, 213.4 runs/s, 428.7 assertions/s.\n\n\ + 500 runs, 1003 assertions, 0 failures, 0 errors, 0 skips", + dots + ); + + let input_tokens = count_tokens(&output); + let result = filter_minitest_output(&output); + let output_tokens = count_tokens(&result); + + let savings = 100.0 - (output_tokens as f64 / input_tokens as f64 * 100.0); + assert!( + savings >= 80.0, + "Expected >= 80% savings, got {:.1}% (input: {}, output: {})", + savings, + input_tokens, + output_tokens + ); + } + + #[test] + fn test_parse_minitest_summary() { + assert_eq!( + parse_minitest_summary("8 runs, 9 assertions, 0 failures, 0 errors, 0 skips"), + (8, 9, 0, 0, 0) + ); + assert_eq!( + parse_minitest_summary("5 runs, 4 assertions, 1 failures, 1 errors, 2 skips"), + (5, 4, 1, 1, 2) + ); + // minitest-reporters uses "tests" instead of "runs" + assert_eq!( + parse_minitest_summary("57 tests, 378 assertions, 0 failures, 0 errors, 0 skips"), + (57, 378, 0, 0, 0) + ); + } + + #[test] + fn test_filter_minitest_multiple_failures() { + let output = r#"Run options: --seed 77777 + +# Running: + +.FF.E. + +Finished in 0.500000s, 12.0 runs/s + + 1) Failure: +TestFoo#test_alpha [/test.rb:10]: +Expected: 1 + Actual: 2 + + 2) Failure: +TestFoo#test_beta [/test.rb:20]: +Expected: "hello" + Actual: "world" + + 3) Error: +TestBar#test_gamma [/test.rb:30]: +NoMethodError: undefined method `blah' + +6 runs, 5 assertions, 2 failures, 1 errors, 0 skips"#; + + let result = filter_minitest_output(output); + assert!(result.contains("2 failures")); + assert!(result.contains("1 errors")); + assert!(result.contains("test_alpha")); + assert!(result.contains("test_beta")); + assert!(result.contains("test_gamma")); + } + + #[test] + fn test_filter_minitest_reporters_format() { + let output = "Started with run options --seed 37764\n\n\ + Progress: |========================================|\n\n\ + Finished in 5.79938s\n\ + 57 tests, 378 assertions, 0 failures, 0 errors, 0 skips"; + + let result = filter_minitest_output(output); + assert!(result.contains("ok rake test")); + assert!(result.contains("57 runs")); + assert!(result.contains("0 failures")); + } + + #[test] + fn test_filter_minitest_with_ansi() { + let output = "\x1b[32mRun options: --seed 12345\x1b[0m\n\n\ + # Running:\n\n\ + \x1b[32m....\x1b[0m\n\n\ + Finished in 0.1s, 40.0 runs/s\n\n\ + 4 runs, 4 assertions, 0 failures, 0 errors, 0 skips"; + + let result = filter_minitest_output(output); + assert!(result.contains("ok rake test")); + assert!(result.contains("4 runs")); + } +} From c97c95f7c346861b72f75654a72238fe0d56d99d Mon Sep 17 00:00:00 2001 From: Nicholas Lee Date: Sun, 15 Mar 2026 18:40:27 -0700 Subject: [PATCH 3/5] feat(ruby): register rake/rspec/rubocop commands and rewrite rules src/main.rs: - Add mod rake_cmd, rspec_cmd, rubocop_cmd - Add Commands::Rake, Rspec, Rubocop variants with trailing_var_arg - Route to respective run() functions - Add to is_operational_command list src/discover/rules.rs: - bundle install/update pattern + rule (TOML, 70% savings) - rake/rails test pattern + rule (85% savings, handles bin/rails) - rspec pattern + rule (65% savings, handles bundle exec) - rubocop pattern + rule (65% savings, handles bundle exec) Rewrite examples: rake test -> rtk rake test bundle exec rake test -> rtk rake test WITH_COVERAGE=true bundle exec rake test -> WITH_COVERAGE=true rtk rake test bundle exec rspec -> rtk rspec bundle exec rubocop -> rtk rubocop bundle install -> rtk bundle install Co-Authored-By: Claude Opus 4.6 Signed-off-by: Nicholas Lee --- src/discover/rules.rs | 44 +++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 39 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/src/discover/rules.rs b/src/discover/rules.rs index 00c793016..060a26340 100644 --- a/src/discover/rules.rs +++ b/src/discover/rules.rs @@ -81,6 +81,11 @@ pub const PATTERNS: &[&str] = &[ r"^trunk\s+build", r"^uv\s+(sync|pip\s+install)\b", r"^yamllint\b", + // Ruby tooling + r"^bundle\s+(install|update)\b", + r"^(?:bundle\s+exec\s+)?(?:bin/)?(?:rake|rails)\s+test", + r"^(?:bundle\s+exec\s+)?rspec(?:\s|$)", + r"^(?:bundle\s+exec\s+)?rubocop(?:\s|$)", ]; pub const RULES: &[RtkRule] = &[ @@ -607,6 +612,45 @@ pub const RULES: &[RtkRule] = &[ subcmd_savings: &[], subcmd_status: &[], }, + // Ruby tooling + RtkRule { + rtk_cmd: "rtk bundle", + rewrite_prefixes: &["bundle"], + category: "Ruby", + savings_pct: 70.0, + subcmd_savings: &[], + subcmd_status: &[], + }, + RtkRule { + rtk_cmd: "rtk rake", + rewrite_prefixes: &[ + "bundle exec rails", + "bundle exec rake", + "bin/rails", + "rails", + "rake", + ], + category: "Ruby", + savings_pct: 85.0, + subcmd_savings: &[("test", 90.0)], + subcmd_status: &[], + }, + RtkRule { + rtk_cmd: "rtk rspec", + rewrite_prefixes: &["bundle exec rspec", "bin/rspec", "rspec"], + category: "Tests", + savings_pct: 65.0, + subcmd_savings: &[], + subcmd_status: &[], + }, + RtkRule { + rtk_cmd: "rtk rubocop", + rewrite_prefixes: &["bundle exec rubocop", "rubocop"], + category: "Build", + savings_pct: 65.0, + subcmd_savings: &[], + subcmd_status: &[], + }, ]; /// Commands to ignore (shell builtins, trivial, already rtk). diff --git a/src/main.rs b/src/main.rs index 2bbc4bb2d..7b866c05a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -46,8 +46,11 @@ mod prettier_cmd; mod prisma_cmd; mod psql_cmd; mod pytest_cmd; +mod rake_cmd; mod read; mod rewrite_cmd; +mod rspec_cmd; +mod rubocop_cmd; mod ruff_cmd; mod runner; mod session_cmd; @@ -634,6 +637,27 @@ enum Commands { args: Vec, }, + /// Rake/Rails test with compact Minitest output (Ruby) + Rake { + /// Rake arguments (e.g., test, test TEST=path/to/test.rb) + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + + /// RuboCop linter with compact output (Ruby) + Rubocop { + /// RuboCop arguments (e.g., --auto-correct, -A) + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + + /// RSpec test runner with compact output (Rails/Ruby) + Rspec { + /// RSpec arguments (e.g., spec/models, --tag focus) + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Mypy type checker with grouped error output Mypy { /// Mypy arguments @@ -1986,6 +2010,18 @@ fn main() -> Result<()> { mypy_cmd::run(&args, cli.verbose)?; } + Commands::Rake { args } => { + rake_cmd::run(&args, cli.verbose)?; + } + + Commands::Rubocop { args } => { + rubocop_cmd::run(&args, cli.verbose)?; + } + + Commands::Rspec { args } => { + rspec_cmd::run(&args, cli.verbose)?; + } + Commands::Pip { args } => { pip_cmd::run(&args, cli.verbose)?; } @@ -2245,6 +2281,9 @@ fn is_operational_command(cmd: &Commands) -> bool { | Commands::Curl { .. } | Commands::Ruff { .. } | Commands::Pytest { .. } + | Commands::Rake { .. } + | Commands::Rubocop { .. } + | Commands::Rspec { .. } | Commands::Pip { .. } | Commands::Go { .. } | Commands::GolangciLint { .. } From 8283f6799894401f80469852ae4263663e449afc Mon Sep 17 00:00:00 2001 From: Nicholas Lee Date: Sun, 15 Mar 2026 18:40:34 -0700 Subject: [PATCH 4/5] feat(ruby): add TOML filter for bundle install/update - bundle-install.toml: strips 'Using' lines, gem metadata, blank lines - Short-circuits to "ok bundle: complete/updated" on success - 4 inline tests: all-cached, mixed install, update, empty - Update builtin filter count (47 -> 48) Co-Authored-By: Claude Opus 4.6 Signed-off-by: Nicholas Lee --- src/filters/bundle-install.toml | 61 +++++++++++++++++++++++++++++++++ src/toml_filter.rs | 10 +++--- 2 files changed, 66 insertions(+), 5 deletions(-) create mode 100644 src/filters/bundle-install.toml diff --git a/src/filters/bundle-install.toml b/src/filters/bundle-install.toml new file mode 100644 index 000000000..80e074862 --- /dev/null +++ b/src/filters/bundle-install.toml @@ -0,0 +1,61 @@ +[filters.bundle-install] +description = "Compact bundle install/update — strip 'Using' lines, keep installs and errors" +match_command = "^bundle\\s+(install|update)\\b" +strip_ansi = true +strip_lines_matching = [ + "^Using ", + "^\\s*$", + "^Fetching gem metadata", + "^Resolving dependencies", +] +match_output = [ + { pattern = "Bundle complete!", message = "ok bundle: complete" }, + { pattern = "Bundle updated!", message = "ok bundle: updated" }, +] +max_lines = 30 + +[[tests.bundle-install]] +name = "all cached short-circuits" +input = """ +Using bundler 2.5.6 +Using rake 13.1.0 +Using ast 2.4.2 +Using base64 0.2.0 +Using minitest 5.22.2 +Bundle complete! 85 Gemfile dependencies, 200 gems now installed. +Use `bundle info [gemname]` to see where a bundled gem is installed. +""" +expected = "ok bundle: complete" + +[[tests.bundle-install]] +name = "mixed install keeps Fetching and Installing lines" +input = """ +Fetching gem metadata from https://rubygems.org/......... +Resolving dependencies... +Using rake 13.1.0 +Using ast 2.4.2 +Fetching rspec 3.13.0 +Installing rspec 3.13.0 +Using rubocop 1.62.0 +Fetching simplecov 0.22.0 +Installing simplecov 0.22.0 +Bundle complete! 85 Gemfile dependencies, 202 gems now installed. +""" +expected = "ok bundle: complete" + +[[tests.bundle-install]] +name = "update output" +input = """ +Fetching gem metadata from https://rubygems.org/......... +Resolving dependencies... +Using rake 13.1.0 +Fetching rspec 3.14.0 (was 3.13.0) +Installing rspec 3.14.0 (was 3.13.0) +Bundle updated! +""" +expected = "ok bundle: updated" + +[[tests.bundle-install]] +name = "empty output" +input = "" +expected = "" diff --git a/src/toml_filter.rs b/src/toml_filter.rs index 69db33bf2..0f571626b 100644 --- a/src/toml_filter.rs +++ b/src/toml_filter.rs @@ -1610,8 +1610,8 @@ match_command = "^make\\b" let filters = make_filters(BUILTIN_TOML); assert_eq!( filters.len(), - 57, - "Expected exactly 57 built-in filters, got {}. \ + 58, + "Expected exactly 58 built-in filters, got {}. \ Update this count when adding/removing filters in src/filters/.", filters.len() ); @@ -1668,11 +1668,11 @@ expected = "output line 1\noutput line 2" let combined = format!("{}\n\n{}", BUILTIN_TOML, new_filter); let filters = make_filters(&combined); - // All 57 existing filters still present + 1 new = 58 + // All 58 existing filters still present + 1 new = 59 assert_eq!( filters.len(), - 58, - "Expected 58 filters after concat (57 built-in + 1 new)" + 59, + "Expected 59 filters after concat (58 built-in + 1 new)" ); // New filter is discoverable From a22df4ff75c61e74603b88dcfc0149b2de15417a Mon Sep 17 00:00:00 2001 From: Nicholas Lee Date: Sun, 15 Mar 2026 18:40:40 -0700 Subject: [PATCH 5/5] docs(ruby): update CLAUDE.md module table and fork features - Add rake_cmd.rs, rspec_cmd.rs, rubocop_cmd.rs to module responsibilities - Add ruby_exec to utils.rs description - Add Ruby on Rails Support section to fork-specific features Co-Authored-By: Claude Opus 4.6 Signed-off-by: Nicholas Lee --- ARCHITECTURE.md | 85 +++++++++++++++++++++++++++++++++++++++++++++++++ CHANGELOG.md | 12 +++++++ CLAUDE.md | 14 +++++++- README.md | 13 +++++++- 4 files changed, 122 insertions(+), 2 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ee375f25e..2b5b73c86 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -272,6 +272,10 @@ PYTHON ruff_cmd.rs ruff check/format 80%+ ✓ GO go_cmd.rs go test/build/vet 75-90% ✓ golangci_cmd.rs golangci-lint 85% ✓ +RUBY rake_cmd.rs rake/rails test 85-90% ✓ + rspec_cmd.rs rspec 60%+ ✓ + rubocop_cmd.rs rubocop 60%+ ✓ + NETWORK wget_cmd.rs wget 85-95% ✓ curl_cmd.rs curl 70% ✓ @@ -303,6 +307,7 @@ SHARED utils.rs Helpers N/A ✓ - **JS/TS Tooling**: 8 modules (modern frontend/fullstack development) - **Python Tooling**: 3 modules (ruff, pytest, pip) - **Go Tooling**: 2 modules (go test/build/vet, golangci-lint) +- **Ruby Tooling**: 3 modules (rake/minitest, rspec, rubocop) + 1 TOML filter (bundle install) --- @@ -605,6 +610,86 @@ pub fn run(command: &GoCommand, verbose: u8) -> Result<()> { - Different output format (JSON API vs text) - Distinct use case (comprehensive linting vs single-tool diagnostics) +### Ruby Module Architecture + +#### Design Rationale + +**Added**: 2026-03-15 +**Motivation**: Ruby on Rails development support (minitest, RSpec, RuboCop, Bundler) + +Ruby modules follow the standalone command pattern (like Python) with a shared `ruby_exec()` utility for auto-detecting `bundle exec`. + +``` +┌────────────────────────────────────────────────────────────────────────┐ +│ Ruby Commands (3 modules + 1 TOML) │ +└────────────────────────────────────────────────────────────────────────┘ + +Module Strategy Output Format Savings +───────────────────────────────────────────────────────────────────────── + +rake_cmd.rs STATE MACHINE Text parser 85-90% + + Minitest output (rake test / rails test): + # Running: + ..F..E.. + Finished in 0.123456s + 1) Failure: TestSomething#test_that_fails [path:15] + + → State machine: Header → Running → Failures → Summary + → All pass: "ok rake test: 8 runs, 0 failures" + → Failures: summary + numbered failure details + → Handles both standard Minitest and minitest-reporters formats + +rspec_cmd.rs JSON/TEXT DUAL • JSON → 60%+ 60%+ + • text fallback + + rspec --format json: Structured test results + → Extract failures with file:line, assertion message + → Fallback to text parsing when JSON unavailable + +rubocop_cmd.rs JSON PARSING JSON API 60%+ + + rubocop --format json: + {"files": [{"path": "x.rb", "offenses": [...]}]} + → Group by cop name and severity + → Format: "Layout/LineLength: 12 offenses, Style/HashSyntax: 5" + +bundle-install.toml TOML FILTER Text rules 70% + + bundle install/update: + → Strip "Using" lines (cached gems), metadata, blank lines + → Short-circuit: "ok bundle: complete" on success +``` + +#### Shared Infrastructure: `ruby_exec()` + +```rust +// utils.rs — auto-detect bundle exec +pub fn ruby_exec(tool: &str) -> Vec { + if Path::new("Gemfile").exists() { + vec!["bundle".into(), "exec".into(), tool.into()] + } else { + vec![tool.into()] + } +} +``` + +Used by: rake_cmd, rspec_cmd, rubocop_cmd. Ensures `bundle exec` is always used in Bundler-managed projects (handles transitive dependencies correctly). + +#### Discover/Rewrite Rules + +``` +rake test → rtk rake test +bundle exec rake → rtk rake test +rails test → rtk rake test +bin/rails test → rtk rake test +bundle exec rspec → rtk rspec +bundle exec rubocop → rtk rubocop +bundle install → rtk bundle install +``` + +ENV_PREFIX auto-strips `RAILS_ENV`, `WITH_COVERAGE`, `BUNDLE_GEMFILE` and re-prepends to the rewritten command. + ### Format Strategy Decision Tree ``` diff --git a/CHANGELOG.md b/CHANGELOG.md index ebc3d7900..867a45e5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to rtk (Rust Token Killer) will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Features + +* **ruby:** add RSpec test runner filter with JSON parsing and text fallback (60%+ reduction) +* **ruby:** add RuboCop linter filter with JSON parsing, grouped by cop/severity (60%+ reduction) +* **ruby:** add minitest filter for `rake test` / `rails test` with state machine parser (85-90% reduction) +* **ruby:** add TOML filter for `bundle install/update` — strip "Using" lines (70% reduction) +* **ruby:** add `ruby_exec()` shared utility for auto-detecting `bundle exec` when Gemfile exists +* **ruby:** add discover/rewrite rules for rake, rails, rspec, rubocop, and bundle commands +>>>>>>> d78401b (docs(ruby): update CLAUDE.md module table and fork features) + ## [0.30.1](https://github.com/rtk-ai/rtk/compare/v0.30.0...v0.30.1) (2026-03-18) diff --git a/CLAUDE.md b/CLAUDE.md index ab5129619..88ca6a542 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -230,8 +230,11 @@ rtk gain --history | grep proxy | pip_cmd.rs | pip/uv package manager | JSON parsing, auto-detect uv (70-85% reduction) | | go_cmd.rs | Go commands | NDJSON for test, text for build/vet (80-90% reduction) | | golangci_cmd.rs | golangci-lint | JSON parsing, group by rule (85% reduction) | +| rake_cmd.rs | Minitest via rake/rails test | Failures only, summary line (85-90% 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) | | tee.rs | Full output recovery | Save raw output to file on failure, print hint for LLM re-read | -| utils.rs | Shared utilities | Package manager detection, common formatting | +| utils.rs | Shared utilities | Package manager detection, ruby_exec, common formatting | | discover/ | Claude Code history analysis | Scan JSONL sessions, classify commands, report missed savings | ## Performance Constraints @@ -392,6 +395,15 @@ pub fn execute_with_filter(cmd: &str, args: &[&str]) -> Result<()> { - **Architecture**: Standalone Python commands (mirror lint/prettier), Go sub-enum (mirror git/cargo) - **Patterns**: JSON for structured output (ruff check, golangci-lint, pip), NDJSON streaming (go test), text state machine (pytest), text filters (go build/vet, ruff format) +### Ruby on Rails Support (2026-03-15) +- **Ruby Commands**: 3 modules for Ruby/Rails development + - `rtk rake test`: Minitest filter via rake/rails test, state machine parser (85-90% reduction) + - `rtk rspec`: RSpec test runner with JSON parsing, text fallback (60%+ reduction) + - `rtk rubocop`: RuboCop linter with JSON parsing, group by cop/severity (60%+ reduction) +- **TOML Filter**: `bundle-install.toml` for bundle install/update (strips Using lines, 70% reduction) +- **Shared Infrastructure**: `ruby_exec()` in utils.rs auto-detects `bundle exec` when Gemfile exists +- **Hook Integration**: Rewrites `rake test`, `rails test`, `bundle exec` variants, `bin/rails test` + ## Testing Strategy ### TDD Workflow (mandatory) diff --git a/README.md b/README.md index d818e2afe..631659df1 100644 --- a/README.md +++ b/README.md @@ -50,8 +50,11 @@ rtk filters and compresses command outputs before they reach your LLM context. S | `ruff check` | 3x | 3,000 | 600 | -80% | | `pytest` | 4x | 8,000 | 800 | -90% | | `go test` | 3x | 6,000 | 600 | -90% | +| `rake test` | 4x | 6,000 | 600 | -90% | +| `rspec` | 3x | 4,500 | 1,800 | -60% | +| `rubocop` | 3x | 3,000 | 1,200 | -60% | | `docker ps` | 3x | 900 | 180 | -80% | -| **Total** | | **~118,000** | **~23,900** | **-80%** | +| **Total** | | **~131,500** | **~27,500** | **-79%** | > Estimates based on medium-sized TypeScript/Rust projects. Actual savings vary by project size. @@ -171,6 +174,8 @@ rtk playwright test # E2E results (failures only) rtk pytest # Python tests (-90%) rtk go test # Go tests (NDJSON, -90%) rtk cargo test # Cargo tests (-90%) +rtk rake test # Ruby minitest (-90%) +rtk rspec # RSpec tests (JSON, -60%+) ``` ### Build & Lint @@ -184,6 +189,7 @@ rtk cargo build # Cargo build (-80%) rtk cargo clippy # Cargo clippy (-80%) rtk ruff check # Python linting (JSON, -80%) rtk golangci-lint run # Go linting (JSON, -85%) +rtk rubocop # Ruby linting (JSON, -60%+) ``` ### Package Managers @@ -191,6 +197,7 @@ rtk golangci-lint run # Go linting (JSON, -85%) rtk pnpm list # Compact dependency tree rtk pip list # Python packages (auto-detect uv) rtk pip outdated # Outdated packages +rtk bundle install # Ruby gems (strip Using lines) rtk prisma generate # Schema generation (no ASCII art) ``` @@ -351,6 +358,10 @@ cp hooks/opencode-rtk.ts ~/.config/opencode/plugins/rtk.ts | `pip list/install` | `rtk pip ...` | | `go test/build/vet` | `rtk go ...` | | `golangci-lint` | `rtk golangci-lint` | +| `rake test` / `rails test` | `rtk rake test` | +| `bundle exec rspec` | `rtk rspec` | +| `bundle exec rubocop` | `rtk rubocop` | +| `bundle install/update` | `rtk bundle ...` | | `docker ps/images/logs` | `rtk docker ...` | | `kubectl get/logs` | `rtk kubectl ...` | | `curl` | `rtk curl` |