From de7666e8a0ff0aa02f1ccab301817e877056e017 Mon Sep 17 00:00:00 2001 From: Benjamin LETELLIER Date: Thu, 26 Mar 2026 22:03:30 +0100 Subject: [PATCH 1/3] feat(php): add PHPUnit filter with compact output (65%+ token savings) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New `rtk phpunit` command: state-machine filter that captures test names, assertion messages, and file locations from PHPUnit output, stripping version banner, progress dots, timing, and memory noise - Supports all invocation variants: `phpunit`, `vendor/bin/phpunit`, `bin/phpunit` (Symfony), `php vendor/bin/phpunit`, `php bin/phpunit` - Binary detection priority: global phpunit → bin/phpunit → vendor/bin/phpunit - Registers in discover/rules.rs for `rtk discover` and hook rewriting - 17 tests: 7 filter tests (including token savings ≥60%), 10 registry tests for classify/rewrite across all variants; real fixture in tests/fixtures/phpunit_raw.txt - "OK (X tests, Y assertions)" → already worked, now also shows counts for the generic fallback path - "OK, but incomplete, skipped, or risky tests!" → new handler shows skipped count (e.g. "PHPUnit: 9 tests, 15 assertions, 2 skipped") - Generic fallback now calls parse_counts to show test/assertion counts instead of bare "PHPUnit: OK" - parse_counts returns 4-tuple (tests, assertions, failures, skipped) to support Skipped: field with trailing-period stripping - 3 new tests covering each success variant - catch segfault + Fatal error # Conflicts: # src/cmds/mod.rs # src/main.rs --- Cargo.lock | 2 +- README.md | 1 + scripts/test-all.sh | 10 + src/cmds/mod.rs | 1 + src/cmds/php/mod.rs | 3 + src/cmds/php/phpunit_cmd.rs | 423 +++++++++++++++++++++++++++++++++ src/discover/registry.rs | 99 ++++++++ src/discover/rules.rs | 97 ++++++++ src/main.rs | 13 + tests/fixtures/phpunit_raw.txt | 22 ++ 10 files changed, 670 insertions(+), 1 deletion(-) create mode 100644 src/cmds/php/mod.rs create mode 100644 src/cmds/php/phpunit_cmd.rs create mode 100644 tests/fixtures/phpunit_raw.txt diff --git a/Cargo.lock b/Cargo.lock index b7796e6d8b..79fa30e31b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -892,7 +892,7 @@ dependencies = [ [[package]] name = "rtk" -version = "0.36.0" +version = "0.34.3" dependencies = [ "anyhow", "automod", diff --git a/README.md b/README.md index a0db81d25c..188a71df85 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,7 @@ rtk rake test # Ruby minitest (-90%) rtk rspec # RSpec tests (JSON, -60%+) rtk err # Filter errors only from any command rtk test # Generic test wrapper - failures only (-90%) +rtk phpunit # PHPUnit tests (failures only, -65%+) ``` ### Build & Lint diff --git a/scripts/test-all.sh b/scripts/test-all.sh index f0e2c06b14..76e4ec6d50 100755 --- a/scripts/test-all.sh +++ b/scripts/test-all.sh @@ -564,6 +564,16 @@ section "Hook check (#344)" assert_contains "rtk init --show hook version" "version" rtk init --show +# ── 42. PHP (conditional) ─────────────────────────── + +section "PHP (conditional)" + +if command -v phpunit &>/dev/null || [ -f vendor/bin/phpunit ] || [ -f bin/phpunit ]; then + assert_help "rtk phpunit" rtk phpunit --help +else + skip_test "rtk phpunit" "phpunit not installed" +fi + # ══════════════════════════════════════════════════════ # Report # ══════════════════════════════════════════════════════ diff --git a/src/cmds/mod.rs b/src/cmds/mod.rs index d064182b3d..ce1443c2db 100644 --- a/src/cmds/mod.rs +++ b/src/cmds/mod.rs @@ -6,6 +6,7 @@ pub mod git; pub mod go; pub mod js; pub mod jvm; +pub mod php; pub mod python; pub mod ruby; pub mod rust; diff --git a/src/cmds/php/mod.rs b/src/cmds/php/mod.rs new file mode 100644 index 0000000000..c4dd2d074d --- /dev/null +++ b/src/cmds/php/mod.rs @@ -0,0 +1,3 @@ +//! PHP ecosystem filters. + +pub mod phpunit_cmd; diff --git a/src/cmds/php/phpunit_cmd.rs b/src/cmds/php/phpunit_cmd.rs new file mode 100644 index 0000000000..107c6a05ef --- /dev/null +++ b/src/cmds/php/phpunit_cmd.rs @@ -0,0 +1,423 @@ +use crate::core::tracking; +use crate::core::utils::{resolved_command, tool_exists}; +use anyhow::{Context, Result}; + +pub fn run(args: &[String], verbose: u8) -> Result<()> { + let timer = tracking::TimedExecution::start(); + + // Priority: global phpunit → Symfony bin/phpunit → Composer vendor/bin/phpunit + let phpunit_script = if std::path::Path::new("bin/phpunit").exists() { + "bin/phpunit" + } else { + "vendor/bin/phpunit" + }; + let mut cmd = if tool_exists("phpunit") { + resolved_command("phpunit") + } else { + let mut c = resolved_command("php"); + c.arg(phpunit_script); + c + }; + + for arg in args { + cmd.arg(arg); + } + + if verbose > 0 { + eprintln!("Running: phpunit {}", args.join(" ")); + } + + let output = cmd + .output() + .context("Failed to run phpunit. Is it installed?")?; + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let raw = format!("{}\n{}", stdout, stderr); + + if raw.to_lowercase().contains("segmentation fault") { + println!("💥 PHPUnit crashed (segfault)"); + std::process::exit(1); + } + + if raw.to_lowercase().contains("fatal error") { + println!("{}", extract_php_fatal(&raw)); + std::process::exit(1); + } + + let filtered = filter_phpunit_output(&stdout); + + let exit_code = output + .status + .code() + .unwrap_or(if output.status.success() { 0 } else { 1 }); + if let Some(hint) = crate::core::tee::tee_and_hint(&raw, "phpunit", exit_code) { + println!("{}\n{}", filtered, hint); + } else { + println!("{}", filtered); + } + + if !stderr.trim().is_empty() { + eprintln!("{}", stderr.trim()); + } + + timer.track( + &format!("phpunit {}", args.join(" ")), + &format!("rtk phpunit {}", args.join(" ")), + &raw, + &filtered, + ); + + if !output.status.success() { + std::process::exit(exit_code); + } + + Ok(()) +} + +fn extract_php_fatal(output: &str) -> String { + let mut result = String::from("💥 PHP Fatal Error\n"); + + for line in output.lines() { + if line.contains("Fatal error") { + result.push_str(line.trim()); + break; + } + } + + result +} + +fn filter_phpunit_output(output: &str) -> String { + let mut failures: Vec> = Vec::new(); + let mut current: Vec = Vec::new(); + let mut in_failures = false; + + for line in output.lines() { + let trimmed = line.trim(); + + // Standard success: "OK (X tests, Y assertions)" + if trimmed.starts_with("OK (") { + return format!("PHPUnit: {}", trimmed); + } + + // Success with skipped/risky/incomplete: "OK, but incomplete, skipped, or risky tests!" + if trimmed.starts_with("OK, but") { + return build_success_with_skipped(output); + } + + // "There was 1 failure:" / "There were N failures:" / errors variant + if (trimmed.starts_with("There was") || trimmed.starts_with("There were")) + && (trimmed.contains("failure") || trimmed.contains("error")) + { + in_failures = true; + continue; + } + + // FAILURES! or ERRORS! marks end of failure details + if trimmed == "FAILURES!" || trimmed == "ERRORS!" { + if !current.is_empty() { + failures.push(std::mem::take(&mut current)); + } + in_failures = false; + continue; + } + + if in_failures { + // New numbered failure: "1) TestClass::testMethod" + if trimmed + .chars() + .next() + .map(|c| c.is_ascii_digit()) + .unwrap_or(false) + && trimmed.contains(')') + { + if !current.is_empty() { + failures.push(std::mem::take(&mut current)); + } + current.push(trimmed.to_string()); + } else if !trimmed.is_empty() { + current.push(trimmed.to_string()); + } + } + } + + if failures.is_empty() { + // No explicit OK line and no failures: show counts from summary line if available + let (tests, assertions, _, _) = parse_counts(output); + if tests > 0 { + return format!("PHPUnit: {} tests, {} assertions", tests, assertions); + } + return "PHPUnit: OK".to_string(); + } + + build_phpunit_summary(output, &failures) +} + +fn build_success_with_skipped(output: &str) -> String { + let (tests, assertions, _, skipped) = parse_counts(output); + if skipped > 0 { + format!( + "PHPUnit: {} tests, {} assertions, {} skipped", + tests, assertions, skipped + ) + } else { + format!("PHPUnit: {} tests, {} assertions", tests, assertions) + } +} + +fn build_phpunit_summary(output: &str, failures: &[Vec]) -> String { + let (tests, assertions, failures_count, _skipped) = parse_counts(output); + + let mut result = format!( + "PHPUnit: {} tests, {} assertions, {} failures\n", + tests, assertions, failures_count + ); + result.push_str("═══════════════════════════════════════\n"); + result.push_str("Failures:\n"); + + for failure_lines in failures.iter().take(10) { + if let Some(first) = failure_lines.first() { + result.push_str(&format!("{}\n", first)); + } + // Show up to 2 detail lines (error message + file location) + for detail in failure_lines.iter().skip(1).take(2) { + result.push_str(&format!(" {}\n", detail)); + } + } + + if failures.len() > 10 { + result.push_str(&format!("... +{} more\n", failures.len() - 10)); + } + + result.trim().to_string() +} + +fn parse_counts(output: &str) -> (usize, usize, usize, usize) { + let mut tests = 0; + let mut assertions = 0; + let mut failures = 0; + let mut skipped = 0; + + for line in output.lines() { + let trimmed = line.trim(); + if !trimmed.starts_with("Tests:") { + continue; + } + + // "Tests: 10, Assertions: 20, Failures: 1." or "Tests: 9, Assertions: 15, Skipped: 2." + for part in trimmed.split(',') { + let mut it = part.split_whitespace(); + let key = it.next().unwrap_or(""); + let val = it + .next() + .unwrap_or("") + .trim_end_matches('.') + .parse() + .unwrap_or(0); + + match key { + "Tests:" => tests = val, + "Assertions:" => assertions = val, + k if k.starts_with("Failures") || k.starts_with("Errors") => failures += val, + k if k.starts_with("Skipped") => skipped = val, + _ => {} + } + } + } + + (tests, assertions, failures, skipped) +} + +#[cfg(test)] +mod tests { + use super::*; + + // Real PHPUnit output: FAILURES! comes AFTER the failure details. + // Uses a large-enough fixture to verify meaningful token savings. + const REAL_PHPUNIT_FAILURE: &str = r#"PHPUnit 10.5.0 by Sebastian Bergmann and contributors. + +Runtime: PHP 8.2.27 with Xdebug 3.3.1 +Configuration: /var/www/html/phpunit.xml + +........................................ 40 / 40 (100%) +.................................................. 80 / 80 (100%) +.F................................................ 100 / 100 (100%) +.......... 110 / 110 (100%) + +Time: 00:01:23.456, Memory: 48.00 MB + +There was 1 failure: + +1) App\Tests\UserTest::testEmailValidation +Failed asserting that false is true. + +#0 /var/www/html/src/User.php:142 (App\User::validate) +#1 /var/www/html/tests/UserTest.php:38 (App\Tests\UserTest::testEmailValidation) + +FAILURES! +Tests: 110, Assertions: 340, Failures: 1."#; + + const REAL_PHPUNIT_SUCCESS: &str = r#"PHPUnit 10.5.0 by Sebastian Bergmann and contributors. + +Runtime: PHP 8.2.0 + +......... 9 / 9 (100%) + +Time: 00:00:00.234, Memory: 6.00 MB + +OK (9 tests, 20 assertions)"#; + + const REAL_PHPUNIT_MULTIPLE_FAILURES: &str = r#"PHPUnit 10.5.0 by Sebastian Bergmann and contributors. + +FF....... 9 / 9 (100%) + +Time: 00:00:00.234, Memory: 6.00 MB + +There were 2 failures: + +1) UserTest::testEmail +Failed asserting that false is true. + +/home/user/tests/UserTest.php:42 + +2) OrderTest::testTotal +Failed asserting that 42 matches expected 100. + +/home/user/tests/OrderTest.php:17 + +FAILURES! +Tests: 9, Assertions: 15, Failures: 2."#; + + fn count_tokens(s: &str) -> usize { + s.split_whitespace().count() + } + + #[test] + fn test_phpunit_success_real_format() { + let result = filter_phpunit_output(REAL_PHPUNIT_SUCCESS); + assert!(result.contains("PHPUnit"), "should contain PHPUnit prefix"); + assert!(result.contains("OK"), "should indicate success"); + assert!(result.contains("9"), "should show test count"); + } + + #[test] + fn test_phpunit_failure_real_format_captures_test_name() { + // Bug: current code captures lines AFTER FAILURES!, missing the details + let result = filter_phpunit_output(REAL_PHPUNIT_FAILURE); + assert!( + result.contains("UserTest::testEmail"), + "must capture test name — got: {}", + result + ); + } + + #[test] + fn test_phpunit_failure_real_format_captures_error_message() { + let result = filter_phpunit_output(REAL_PHPUNIT_FAILURE); + assert!( + result.contains("Failed asserting"), + "must capture assertion message — got: {}", + result + ); + } + + #[test] + fn test_phpunit_multiple_failures() { + let result = filter_phpunit_output(REAL_PHPUNIT_MULTIPLE_FAILURES); + assert!( + result.contains("UserTest::testEmail"), + "must capture first failure" + ); + assert!( + result.contains("OrderTest::testTotal"), + "must capture second failure" + ); + } + + #[test] + fn test_parse_counts_with_trailing_period() { + // Bug: "Failures: 1." has trailing period, parse::() fails → returns 0 + let (_, _, failures, _) = parse_counts(REAL_PHPUNIT_FAILURE); + assert_eq!( + failures, 1, + "must parse failure count despite trailing period" + ); + } + + #[test] + fn test_phpunit_token_savings() { + let result = filter_phpunit_output(REAL_PHPUNIT_FAILURE); + let input_tokens = count_tokens(REAL_PHPUNIT_FAILURE); + let output_tokens = count_tokens(&result); + let savings = 100.0 - (output_tokens as f64 / input_tokens as f64 * 100.0); + assert!( + savings >= 65.0, + "expected ≥65% token savings, got {:.1}% (in={}, out={})", + savings, + input_tokens, + output_tokens + ); + } + + #[test] + fn test_phpunit_empty_input() { + let result = filter_phpunit_output(""); + assert!(!result.is_empty(), "should not panic on empty input"); + } + + // --- Success variants --- + + #[test] + fn test_phpunit_success_shows_counts() { + // Fallback "PHPUnit: OK" must show counts from the Tests: summary line + let output = r#"PHPUnit 10.5.0 by Sebastian Bergmann and contributors. + +......... 9 / 9 (100%) + +Time: 00:00:00.234, Memory: 6.00 MB + +OK (9 tests, 20 assertions)"#; + let result = filter_phpunit_output(output); + assert!( + result.contains("9"), + "must show test count — got: {}", + result + ); + assert!( + result.contains("20"), + "must show assertion count — got: {}", + result + ); + } + + #[test] + fn test_phpunit_success_with_skipped() { + // PHPUnit shows "OK, but incomplete, skipped, or risky tests!" when tests are skipped. + // Must show skipped count, not bare "PHPUnit: OK". + let output = r#"PHPUnit 10.5.0 by Sebastian Bergmann and contributors. + +.S....... 9 / 9 (100%) + +Time: 00:00:00.234, Memory: 6.00 MB + +OK, but incomplete, skipped, or risky tests! +Tests: 9, Assertions: 15, Skipped: 2."#; + let result = filter_phpunit_output(output); + assert!( + result.contains("2") && result.contains("skipped"), + "must show skipped count — got: {}", + result + ); + } + + #[test] + fn test_parse_counts_skipped() { + let output = "Tests: 9, Assertions: 15, Skipped: 2."; + let (tests, assertions, failures, skipped) = parse_counts(output); + assert_eq!(tests, 9); + assert_eq!(assertions, 15); + assert_eq!(failures, 0); + assert_eq!(skipped, 2, "must parse Skipped field"); + } +} diff --git a/src/discover/registry.rs b/src/discover/registry.rs index bb7b11f2a4..14eab785cd 100644 --- a/src/discover/registry.rs +++ b/src/discover/registry.rs @@ -3884,4 +3884,103 @@ mod tests { Some("rtk git log | head | tail && rtk git status".into()) ); } + + // --- PHP tooling --- + + #[test] + fn test_classify_phpunit() { + assert!(matches!( + classify_command("phpunit tests/"), + Classification::Supported { + rtk_equivalent: "rtk phpunit", + .. + } + )); + } + + #[test] + fn test_classify_vendor_bin_phpunit() { + assert!(matches!( + classify_command("vendor/bin/phpunit --filter EmailTest"), + Classification::Supported { + rtk_equivalent: "rtk phpunit", + .. + } + )); + } + + #[test] + fn test_classify_php_vendor_bin_phpunit() { + assert!(matches!( + classify_command("php vendor/bin/phpunit tests/"), + Classification::Supported { + rtk_equivalent: "rtk phpunit", + .. + } + )); + } + + #[test] + fn test_classify_bin_phpunit() { + // bin/phpunit is used by Symfony apps + assert!(matches!( + classify_command("bin/phpunit tests/"), + Classification::Supported { + rtk_equivalent: "rtk phpunit", + .. + } + )); + } + + #[test] + fn test_classify_php_bin_phpunit() { + assert!(matches!( + classify_command("php bin/phpunit --filter EmailTest"), + Classification::Supported { + rtk_equivalent: "rtk phpunit", + .. + } + )); + } + + #[test] + fn test_rewrite_phpunit() { + assert_eq!( + rewrite_command("phpunit tests/", &[]), + Some("rtk phpunit tests/".into()) + ); + } + + #[test] + fn test_rewrite_vendor_bin_phpunit() { + assert_eq!( + rewrite_command("vendor/bin/phpunit --filter EmailTest", &[]), + Some("rtk phpunit --filter EmailTest".into()) + ); + } + + #[test] + fn test_rewrite_php_vendor_bin_phpunit() { + assert_eq!( + rewrite_command("php vendor/bin/phpunit tests/", &[]), + Some("rtk phpunit tests/".into()) + ); + } + + #[test] + fn test_rewrite_bin_phpunit() { + // bin/phpunit used by Symfony apps + assert_eq!( + rewrite_command("bin/phpunit tests/", &[]), + Some("rtk phpunit tests/".into()) + ); + } + + #[test] + fn test_rewrite_php_bin_phpunit() { + assert_eq!( + rewrite_command("php bin/phpunit --filter EmailTest", &[]), + Some("rtk phpunit --filter EmailTest".into()) + ); + } } diff --git a/src/discover/rules.rs b/src/discover/rules.rs index df7c72d03e..e65a77bdcc 100644 --- a/src/discover/rules.rs +++ b/src/discover/rules.rs @@ -10,6 +10,86 @@ pub struct RtkRule { pub subcmd_status: &'static [(&'static str, RtkStatus)], } +// Patterns ordered to match RULES indices exactly. +pub const PATTERNS: &[&str] = &[ + r"^git\s+(?:-[Cc]\s+\S+\s+)*(status|log|diff|show|add|commit|push|pull|branch|fetch|stash|worktree)", + r"^gh\s+(pr|issue|run|repo|api|release)", + r"^cargo\s+(build|test|clippy|check|fmt|install)", + r"^pnpm\s+(list|ls|outdated|install)", + r"^npm\s+(run|exec)", + r"^npx\s+", + r"^(cat|head|tail)\s+", + r"^(rg|grep)\s+", + r"^ls(\s|$)", + r"^find\s+", + r"^(npx\s+|pnpm\s+)?tsc(\s|$)", + r"^(npx\s+|pnpm\s+)?(eslint|biome|lint)(\s|$)", + r"^(npx\s+|pnpm\s+)?prettier", + r"^(npx\s+|pnpm\s+)?next\s+build", + r"^(pnpm\s+|npx\s+)?(vitest|jest|test)(\s|$)", + r"^(npx\s+|pnpm\s+)?playwright", + r"^(npx\s+|pnpm\s+)?prisma", + r"^docker\s+(ps|images|logs|run|exec|build|compose\s+(ps|logs|build))", + r"^kubectl\s+(get|logs|describe|apply)", + r"^tree(\s|$)", + r"^diff\s+", + r"^curl\s+", + r"^wget\s+", + r"^(python3?\s+-m\s+)?mypy(\s|$)", + // Python tooling + r"^ruff\s+(check|format)", + r"^(python\s+-m\s+)?pytest(\s|$)", + r"^(pip3?|uv\s+pip)\s+(list|outdated|install)", + // Go tooling + r"^go\s+(test|build|vet)", + r"^golangci-lint(\s|$)", + // Ruby tooling + r"^bundle\s+(install|update)\b", + r"^(?:bundle\s+exec\s+)?(?:bin/)?(?:rake|rails)\s+test", + r"^(?:bundle\s+exec\s+)?rspec(?:\s|$)", + r"^(?:bundle\s+exec\s+)?rubocop(?:\s|$)", + // PHP tooling + r"^(?:php\s+)?(?:(?:vendor/bin|bin)/)?phpunit(?:\s|$)", + // AWS CLI + r"^aws\s+", + // PostgreSQL + r"^psql(\s|$)", + // TOML-filtered commands + r"^ansible-playbook\b", + r"^brew\s+(install|upgrade)\b", + r"^composer\s+(install|update|require)\b", + r"^df(\s|$)", + r"^dotnet\s+build\b", + r"^du\b", + r"^fail2ban-client\b", + r"^gcloud\b", + r"^hadolint\b", + r"^helm\b", + r"^iptables\b", + r"^make\b", + r"^markdownlint\b", + r"^mix\s+(compile|format)(\s|$)", + r"^mvn\s+(compile|package|clean|install)\b", + r"^ping\b", + r"^pio\s+run", + r"^poetry\s+(install|lock|update)\b", + r"^pre-commit\b", + r"^ps(\s|$)", + r"^quarto\s+render", + r"^rsync\b", + r"^shellcheck\b", + r"^shopify\s+theme\s+(push|pull)", + r"^sops\b", + r"^swift\s+(build|test)\b", + r"^systemctl\s+status\b", + r"^terraform\s+plan", + r"^tofu\s+(fmt|init|plan|validate)(\s|$)", + r"^trunk\s+build", + r"^uv\s+(sync|pip\s+install)\b", + r"^yamllint\b", +]; + + pub const RULES: &[RtkRule] = &[ RtkRule { pattern: r"^(?:git|yadm)\s+(?:-[Cc]\s+\S+\s+)*(status|log|diff|show|add|commit|push|pull|branch|fetch|stash|worktree)", @@ -521,6 +601,23 @@ pub const RULES: &[RtkRule] = &[ subcmd_savings: &[], subcmd_status: &[], }, + // PHP tooling + RtkRule { + pattern: r"^(?:php\s+)?(?:(?:vendor/bin|bin)/)?phpunit(?:\s|$)", + rtk_cmd: "rtk phpunit", + rewrite_prefixes: &[ + "php vendor/bin/phpunit", + "php bin/phpunit", + "vendor/bin/phpunit", + "bin/phpunit", + "phpunit", + ], + category: "Tests", + savings_pct: 65.0, + subcmd_savings: &[], + subcmd_status: &[], + }, + // AWS CLI RtkRule { pattern: r"^aws\s+", rtk_cmd: "rtk aws", diff --git a/src/main.rs b/src/main.rs index c1a8971908..2b0f2ce183 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,6 +16,7 @@ use cmds::js::{ vitest_cmd, }; use cmds::jvm::gradlew_cmd; +use cmds::php::phpunit_cmd; use cmds::python::{mypy_cmd, pip_cmd, pytest_cmd, ruff_cmd}; use cmds::ruby::{rake_cmd, rspec_cmd, rubocop_cmd}; use cmds::rust::{cargo_cmd, runner}; @@ -762,6 +763,12 @@ enum Commands { #[command(subcommand)] command: HookCommands, }, + + /// Run PHPUnit tests with compact output (PHP) + Phpunit { + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, } #[derive(Debug, Subcommand)] @@ -2445,6 +2452,11 @@ fn run_cli() -> Result { } 0 } + + Commands::Phpunit { args } => { + phpunit_cmd::run(&args, cli.verbose)?; + 0 + } }; Ok(code) @@ -2505,6 +2517,7 @@ fn is_operational_command(cmd: &Commands) -> bool { | Commands::Go { .. } | Commands::GolangciLint { .. } | Commands::Gt { .. } + | Commands::Phpunit { .. } ) } diff --git a/tests/fixtures/phpunit_raw.txt b/tests/fixtures/phpunit_raw.txt new file mode 100644 index 0000000000..a33cb9b817 --- /dev/null +++ b/tests/fixtures/phpunit_raw.txt @@ -0,0 +1,22 @@ +PHPUnit 10.5.0 by Sebastian Bergmann and contributors. + +Runtime: PHP 8.2.27 with Xdebug 3.3.1 +Configuration: /var/www/html/phpunit.xml + +........................................ 40 / 40 (100%) +.................................................. 80 / 80 (100%) +.F................................................ 100 / 100 (100%) +.......... 110 / 110 (100%) + +Time: 00:01:23.456, Memory: 48.00 MB + +There was 1 failure: + +1) App\Tests\UserTest::testEmailValidation +Failed asserting that false is true. + +#0 /var/www/html/src/User.php:142 (App\User::validate) +#1 /var/www/html/tests/UserTest.php:38 (App\Tests\UserTest::testEmailValidation) + +FAILURES! +Tests: 110, Assertions: 340, Failures: 1. From 57397458c8ef4dafbd054c8215634dd07ab7878c Mon Sep 17 00:00:00 2001 From: Benjamin LETELLIER Date: Sat, 25 Apr 2026 14:32:52 +0200 Subject: [PATCH 2/3] rebase cargo.lock --- Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 79fa30e31b..b7796e6d8b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -892,7 +892,7 @@ dependencies = [ [[package]] name = "rtk" -version = "0.34.3" +version = "0.36.0" dependencies = [ "anyhow", "automod", From f97e423dc65c9879aea2bea41fab7375fdfa0145 Mon Sep 17 00:00:00 2001 From: Benjamin LETELLIER Date: Tue, 19 May 2026 10:50:53 +0200 Subject: [PATCH 3/3] fix(discover): fix PHPUnit tests after rewrite_command gained transparent_prefixes arg Remove unused PATTERNS const that triggered dead-code error. Co-Authored-By: Claude Sonnet 4.6 --- Cargo.lock | 2 +- src/discover/registry.rs | 10 ++--- src/discover/rules.rs | 80 ---------------------------------------- 3 files changed, 6 insertions(+), 86 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b7796e6d8b..79fa30e31b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -892,7 +892,7 @@ dependencies = [ [[package]] name = "rtk" -version = "0.36.0" +version = "0.34.3" dependencies = [ "anyhow", "automod", diff --git a/src/discover/registry.rs b/src/discover/registry.rs index 14eab785cd..b5f5944d0c 100644 --- a/src/discover/registry.rs +++ b/src/discover/registry.rs @@ -3946,7 +3946,7 @@ mod tests { #[test] fn test_rewrite_phpunit() { assert_eq!( - rewrite_command("phpunit tests/", &[]), + rewrite_command("phpunit tests/", &[], &[]), Some("rtk phpunit tests/".into()) ); } @@ -3954,7 +3954,7 @@ mod tests { #[test] fn test_rewrite_vendor_bin_phpunit() { assert_eq!( - rewrite_command("vendor/bin/phpunit --filter EmailTest", &[]), + rewrite_command("vendor/bin/phpunit --filter EmailTest", &[], &[]), Some("rtk phpunit --filter EmailTest".into()) ); } @@ -3962,7 +3962,7 @@ mod tests { #[test] fn test_rewrite_php_vendor_bin_phpunit() { assert_eq!( - rewrite_command("php vendor/bin/phpunit tests/", &[]), + rewrite_command("php vendor/bin/phpunit tests/", &[], &[]), Some("rtk phpunit tests/".into()) ); } @@ -3971,7 +3971,7 @@ mod tests { fn test_rewrite_bin_phpunit() { // bin/phpunit used by Symfony apps assert_eq!( - rewrite_command("bin/phpunit tests/", &[]), + rewrite_command("bin/phpunit tests/", &[], &[]), Some("rtk phpunit tests/".into()) ); } @@ -3979,7 +3979,7 @@ mod tests { #[test] fn test_rewrite_php_bin_phpunit() { assert_eq!( - rewrite_command("php bin/phpunit --filter EmailTest", &[]), + rewrite_command("php bin/phpunit --filter EmailTest", &[], &[]), Some("rtk phpunit --filter EmailTest".into()) ); } diff --git a/src/discover/rules.rs b/src/discover/rules.rs index e65a77bdcc..a32c55eca4 100644 --- a/src/discover/rules.rs +++ b/src/discover/rules.rs @@ -10,86 +10,6 @@ pub struct RtkRule { pub subcmd_status: &'static [(&'static str, RtkStatus)], } -// Patterns ordered to match RULES indices exactly. -pub const PATTERNS: &[&str] = &[ - r"^git\s+(?:-[Cc]\s+\S+\s+)*(status|log|diff|show|add|commit|push|pull|branch|fetch|stash|worktree)", - r"^gh\s+(pr|issue|run|repo|api|release)", - r"^cargo\s+(build|test|clippy|check|fmt|install)", - r"^pnpm\s+(list|ls|outdated|install)", - r"^npm\s+(run|exec)", - r"^npx\s+", - r"^(cat|head|tail)\s+", - r"^(rg|grep)\s+", - r"^ls(\s|$)", - r"^find\s+", - r"^(npx\s+|pnpm\s+)?tsc(\s|$)", - r"^(npx\s+|pnpm\s+)?(eslint|biome|lint)(\s|$)", - r"^(npx\s+|pnpm\s+)?prettier", - r"^(npx\s+|pnpm\s+)?next\s+build", - r"^(pnpm\s+|npx\s+)?(vitest|jest|test)(\s|$)", - r"^(npx\s+|pnpm\s+)?playwright", - r"^(npx\s+|pnpm\s+)?prisma", - r"^docker\s+(ps|images|logs|run|exec|build|compose\s+(ps|logs|build))", - r"^kubectl\s+(get|logs|describe|apply)", - r"^tree(\s|$)", - r"^diff\s+", - r"^curl\s+", - r"^wget\s+", - r"^(python3?\s+-m\s+)?mypy(\s|$)", - // Python tooling - r"^ruff\s+(check|format)", - r"^(python\s+-m\s+)?pytest(\s|$)", - r"^(pip3?|uv\s+pip)\s+(list|outdated|install)", - // Go tooling - r"^go\s+(test|build|vet)", - r"^golangci-lint(\s|$)", - // Ruby tooling - r"^bundle\s+(install|update)\b", - r"^(?:bundle\s+exec\s+)?(?:bin/)?(?:rake|rails)\s+test", - r"^(?:bundle\s+exec\s+)?rspec(?:\s|$)", - r"^(?:bundle\s+exec\s+)?rubocop(?:\s|$)", - // PHP tooling - r"^(?:php\s+)?(?:(?:vendor/bin|bin)/)?phpunit(?:\s|$)", - // AWS CLI - r"^aws\s+", - // PostgreSQL - r"^psql(\s|$)", - // TOML-filtered commands - r"^ansible-playbook\b", - r"^brew\s+(install|upgrade)\b", - r"^composer\s+(install|update|require)\b", - r"^df(\s|$)", - r"^dotnet\s+build\b", - r"^du\b", - r"^fail2ban-client\b", - r"^gcloud\b", - r"^hadolint\b", - r"^helm\b", - r"^iptables\b", - r"^make\b", - r"^markdownlint\b", - r"^mix\s+(compile|format)(\s|$)", - r"^mvn\s+(compile|package|clean|install)\b", - r"^ping\b", - r"^pio\s+run", - r"^poetry\s+(install|lock|update)\b", - r"^pre-commit\b", - r"^ps(\s|$)", - r"^quarto\s+render", - r"^rsync\b", - r"^shellcheck\b", - r"^shopify\s+theme\s+(push|pull)", - r"^sops\b", - r"^swift\s+(build|test)\b", - r"^systemctl\s+status\b", - r"^terraform\s+plan", - r"^tofu\s+(fmt|init|plan|validate)(\s|$)", - r"^trunk\s+build", - r"^uv\s+(sync|pip\s+install)\b", - r"^yamllint\b", -]; - - pub const RULES: &[RtkRule] = &[ RtkRule { pattern: r"^(?:git|yadm)\s+(?:-[Cc]\s+\S+\s+)*(status|log|diff|show|add|commit|push|pull|branch|fetch|stash|worktree)",