From d6ee90bd32863fba9905160faa5dc9cefc4ebf6f Mon Sep 17 00:00:00 2001 From: dlmw <12473240+dlmw@users.noreply.github.com> Date: Mon, 23 Feb 2026 10:52:12 +0100 Subject: [PATCH 1/2] feat: add support for `mvn test` --- CLAUDE.md | 53 +-- README.md | 7 + hooks/rtk-rewrite.sh | 6 + scripts/test-all.sh | 17 +- src/discover/registry.rs | 47 +++ src/main.rs | 60 +++ src/mvn_cmd.rs | 863 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 1024 insertions(+), 29 deletions(-) create mode 100644 src/mvn_cmd.rs diff --git a/CLAUDE.md b/CLAUDE.md index b8cf94f0..76eff493 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -206,32 +206,33 @@ rtk gain --history | grep proxy ### Module Responsibilities -| Module | Purpose | Token Strategy | -|--------|---------|----------------| -| git.rs | Git operations | Stat summaries + compact diffs | -| grep_cmd.rs | Code search | Group by file, truncate lines | -| ls.rs | Directory listing | Tree format, aggregate counts | -| read.rs | File reading | Filter-level based stripping | -| runner.rs | Command execution | Stderr only (err), failures only (test) | -| log_cmd.rs | Log parsing | Deduplication with counts | -| json_cmd.rs | JSON inspection | Structure without values | -| lint_cmd.rs | ESLint/Biome linting | Group by rule, file summary (84% reduction) | -| tsc_cmd.rs | TypeScript compiler | Group by file/error code (83% reduction) | -| next_cmd.rs | Next.js build/dev | Route metrics, bundle stats only (87% reduction) | -| prettier_cmd.rs | Format checking | Files needing changes only (70% reduction) | -| playwright_cmd.rs | E2E test results | Failures only, grouped by suite (94% reduction) | -| prisma_cmd.rs | Prisma CLI | Strip ASCII art and verbose output (88% reduction) | -| gh_cmd.rs | GitHub CLI | Compact PR/issue/run views (26-87% reduction) | -| vitest_cmd.rs | Vitest test runner | Failures only with ANSI stripping (99.5% reduction) | -| pnpm_cmd.rs | pnpm package manager | Compact dependency trees (70-90% reduction) | -| ruff_cmd.rs | Ruff linter/formatter | JSON for check, text for format (80%+ reduction) | -| pytest_cmd.rs | Pytest test runner | State machine text parser (90%+ reduction) | -| 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) | -| 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 | -| discover/ | Claude Code history analysis | Scan JSONL sessions, classify commands, report missed savings | +| Module | Purpose | Token Strategy | +|--------|---------|-----------------------------------------------------------------| +| git.rs | Git operations | Stat summaries + compact diffs | +| grep_cmd.rs | Code search | Group by file, truncate lines | +| ls.rs | Directory listing | Tree format, aggregate counts | +| read.rs | File reading | Filter-level based stripping | +| runner.rs | Command execution | Stderr only (err), failures only (test) | +| log_cmd.rs | Log parsing | Deduplication with counts | +| json_cmd.rs | JSON inspection | Structure without values | +| lint_cmd.rs | ESLint/Biome linting | Group by rule, file summary (84% reduction) | +| tsc_cmd.rs | TypeScript compiler | Group by file/error code (83% reduction) | +| next_cmd.rs | Next.js build/dev | Route metrics, bundle stats only (87% reduction) | +| prettier_cmd.rs | Format checking | Files needing changes only (70% reduction) | +| playwright_cmd.rs | E2E test results | Failures only, grouped by suite (94% reduction) | +| prisma_cmd.rs | Prisma CLI | Strip ASCII art and verbose output (88% reduction) | +| gh_cmd.rs | GitHub CLI | Compact PR/issue/run views (26-87% reduction) | +| vitest_cmd.rs | Vitest test runner | Failures only with ANSI stripping (99.5% reduction) | +| pnpm_cmd.rs | pnpm package manager | Compact dependency trees (70-90% reduction) | +| ruff_cmd.rs | Ruff linter/formatter | JSON for check, text for format (80%+ reduction) | +| pytest_cmd.rs | Pytest test runner | State machine text parser (90%+ reduction) | +| 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) | +| mvn_cmd.rs | Maven commands | State machine parser, strip downloads/lifecycle (99% 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 | +| discover/ | Claude Code history analysis | Scan JSONL sessions, classify commands, report missed savings | ## Performance Constraints diff --git a/README.md b/README.md index b6537eab..daa07d52 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,7 @@ rtk pytest # Python tests (failures only, 90% reduction) rtk pip list # Python packages (auto-detect uv, 70% reduction) rtk go test # Go tests (NDJSON, 90% reduction) rtk golangci-lint run # Go linting (JSON, 85% reduction) +rtk mvn test # Maven tests (state machine, 99% reduction) ``` ### Data & Analytics @@ -282,6 +283,11 @@ rtk go test # NDJSON streaming parser (90% reduction) rtk go build # Build errors only (80% reduction) rtk go vet # Vet issues (75% reduction) rtk golangci-lint run # JSON grouped by rule (85% reduction) + +# Maven +rtk mvn test # Strip downloads/lifecycle, failures only (99% reduction) +rtk mvn test -pl module-a # Multi-module with Maven args passthrough +rtk mvn package # Passthrough for non-test subcommands ``` ## Examples @@ -625,6 +631,7 @@ The hook is included in this repository at `.claude/hooks/rtk-rewrite.sh`. To us | `pip list/install/outdated` | `rtk pip ...` | | `go test/build/vet` | `rtk go ...` | | `golangci-lint run` | `rtk golangci-lint run` | +| `mvn test/package/...` | `rtk mvn ...` | | `docker ps/images/logs` | `rtk docker ...` | | `kubectl get/logs` | `rtk kubectl ...` | | `curl` | `rtk curl` | diff --git a/hooks/rtk-rewrite.sh b/hooks/rtk-rewrite.sh index 59e02caa..b799aed6 100644 --- a/hooks/rtk-rewrite.sh +++ b/hooks/rtk-rewrite.sh @@ -185,6 +185,12 @@ elif echo "$MATCH_CMD" | grep -qE '^go[[:space:]]+vet([[:space:]]|$)'; then REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^go vet/rtk go vet/')" elif echo "$MATCH_CMD" | grep -qE '^golangci-lint([[:space:]]|$)'; then REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed 's/^golangci-lint/rtk golangci-lint/')" + +# --- Maven tooling --- +elif echo "$MATCH_CMD" | grep -qE '^(\.?/?mvnw?|mvn)[[:space:]]+test([[:space:]]|$)'; then + REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed -E 's/^(\.?\/?mvnw?|mvn) test/rtk mvn test/')" +elif echo "$MATCH_CMD" | grep -qE '^(\.?/?mvnw?|mvn)[[:space:]]'; then + REWRITTEN="${ENV_PREFIX}$(echo "$CMD_BODY" | sed -E 's/^(\.?\/?mvnw?|mvn) /rtk mvn /')" fi # If no rewrite needed, approve as-is diff --git a/scripts/test-all.sh b/scripts/test-all.sh index 74203f49..cfc37305 100755 --- a/scripts/test-all.sh +++ b/scripts/test-all.sh @@ -413,20 +413,31 @@ else skip "golangci-lint not installed" fi -# ── 29. Global flags ──────────────────────────────── +# ── 29. Maven (conditional) ──────────────────────── + +section "Maven (conditional)" + +if command -v mvn &>/dev/null; then + assert_help "rtk mvn" rtk mvn --help + assert_help "rtk mvn test" rtk mvn test -h +else + skip "maven not installed" +fi + +# ── 30. Global flags ──────────────────────────────── section "Global flags" assert_ok "rtk -u ls ." rtk -u ls . assert_ok "rtk --skip-env npm --help" rtk --skip-env npm --help -# ── 30. CcEconomics ───────────────────────────────── +# ── 31. CcEconomics ───────────────────────────────── section "CcEconomics" assert_ok "rtk cc-economics" rtk cc-economics -# ── 31. Learn ─────────────────────────────────────── +# ── 32. Learn ─────────────────────────────────────── section "Learn" diff --git a/src/discover/registry.rs b/src/discover/registry.rs index 7ef375cd..d42f3ad4 100644 --- a/src/discover/registry.rs +++ b/src/discover/registry.rs @@ -70,6 +70,7 @@ const PATTERNS: &[&str] = &[ r"^kubectl\s+(get|logs)", r"^curl\s+", r"^wget\s+", + r"^(?:\.?/?mvnw?|mvn)\s+(test|compile|package|install|clean|verify)", ]; const RULES: &[RtkRule] = &[ @@ -225,6 +226,13 @@ const RULES: &[RtkRule] = &[ subcmd_savings: &[], subcmd_status: &[], }, + RtkRule { + rtk_cmd: "rtk mvn", + category: "Build", + savings_pct: 99.0, + subcmd_savings: &[("test", 99.0)], + subcmd_status: &[], + }, ]; /// Commands to ignore (shell builtins, trivial, already rtk). @@ -699,6 +707,45 @@ mod tests { } } + #[test] + fn test_classify_mvn_test() { + assert_eq!( + classify_command("mvn test"), + Classification::Supported { + rtk_equivalent: "rtk mvn", + category: "Build", + estimated_savings_pct: 99.0, + status: RtkStatus::Existing, + } + ); + } + + #[test] + fn test_classify_mvnw_test() { + assert_eq!( + classify_command("./mvnw test -pl module-a"), + Classification::Supported { + rtk_equivalent: "rtk mvn", + category: "Build", + estimated_savings_pct: 99.0, + status: RtkStatus::Existing, + } + ); + } + + #[test] + fn test_classify_mvn_package() { + assert_eq!( + classify_command("mvn package -DskipTests"), + Classification::Supported { + rtk_equivalent: "rtk mvn", + category: "Build", + estimated_savings_pct: 99.0, + status: RtkStatus::Existing, + } + ); + } + #[test] fn test_split_chain_and() { assert_eq!(split_command_chain("a && b"), vec!["a", "b"]); diff --git a/src/main.rs b/src/main.rs index fcb39303..c8342032 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,6 +26,7 @@ mod lint_cmd; mod local_llm; mod log_cmd; mod ls; +mod mvn_cmd; mod next_cmd; mod npm_cmd; mod parser; @@ -522,6 +523,12 @@ enum Commands { command: GoCommands, }, + /// Maven commands with compact output + Mvn { + #[command(subcommand)] + command: MvnCommands, + }, + /// golangci-lint with compact output #[command(name = "golangci-lint")] GolangciLint { @@ -852,6 +859,19 @@ enum GoCommands { Other(Vec), } +#[derive(Subcommand)] +enum MvnCommands { + /// Run tests with compact output (99% token reduction) + Test { + /// Additional mvn test arguments (e.g., -pl module-a, -Dtest=FooTest) + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Passthrough: runs any unsupported mvn subcommand directly + #[command(external_subcommand)] + Other(Vec), +} + fn main() -> Result<()> { let cli = Cli::parse(); @@ -1423,6 +1443,15 @@ fn main() -> Result<()> { } }, + Commands::Mvn { command } => match command { + MvnCommands::Test { args } => { + mvn_cmd::run_test(&args, cli.verbose)?; + } + MvnCommands::Other(args) => { + mvn_cmd::run_other(&args, cli.verbose)?; + } + }, + Commands::GolangciLint { args } => { golangci_cmd::run(&args, cli.verbose)?; } @@ -1546,4 +1575,35 @@ mod tests { _ => panic!("Expected Git Commit command"), } } + + #[test] + fn test_mvn_test_parsing() { + let cli = Cli::try_parse_from(["rtk", "mvn", "test", "-pl", "module-a", "-Dtest=FooTest"]) + .unwrap(); + match cli.command { + Commands::Mvn { + command: MvnCommands::Test { args }, + } => { + assert_eq!(args, vec!["-pl", "module-a", "-Dtest=FooTest"]); + } + _ => panic!("Expected Mvn Test command"), + } + } + + #[test] + fn test_mvn_passthrough() { + let cli = Cli::try_parse_from(["rtk", "mvn", "package", "-DskipTests"]).unwrap(); + match cli.command { + Commands::Mvn { + command: MvnCommands::Other(args), + } => { + let strs: Vec = args + .iter() + .map(|a| a.to_string_lossy().into_owned()) + .collect(); + assert_eq!(strs, vec!["package", "-DskipTests"]); + } + _ => panic!("Expected Mvn Other command"), + } + } } diff --git a/src/mvn_cmd.rs b/src/mvn_cmd.rs new file mode 100644 index 00000000..ea143afb --- /dev/null +++ b/src/mvn_cmd.rs @@ -0,0 +1,863 @@ +use crate::tracking; +use crate::utils::truncate; +use anyhow::{Context, Result}; +use lazy_static::lazy_static; +use regex::Regex; +use std::ffi::OsString; +use std::process::Command; + +lazy_static! { + /// Matches Maven test summary: "Tests run: N, Failures: N, Errors: N, Skipped: N" + static ref TEST_SUMMARY_RE: Regex = + Regex::new(r"Tests run:\s*(\d+),\s*Failures:\s*(\d+),\s*Errors:\s*(\d+),\s*Skipped:\s*(\d+)") + .expect("invalid test summary regex"); + /// Matches Maven total time: "Total time: 5.123 s" + static ref TOTAL_TIME_RE: Regex = + Regex::new(r"Total time:\s*(.+)") + .expect("invalid total time regex"); + /// Matches module marker: "--------< groupId:artifactId >--------" + static ref MODULE_MARKER_RE: Regex = + Regex::new(r"^-+<\s*\S+\s*>-+$") + .expect("invalid module marker regex"); +} + +#[derive(Debug, PartialEq)] +enum ParseState { + Preamble, + TestSection, + Failures, + Summary, +} + +#[derive(Debug, Default)] +struct TestCounts { + run: usize, + failures: usize, + errors: usize, + skipped: usize, +} + +impl TestCounts { + fn accumulate(&mut self, other: &TestCounts) { + self.run += other.run; + self.failures += other.failures; + self.errors += other.errors; + self.skipped += other.skipped; + } + + fn has_failures(&self) -> bool { + self.failures > 0 || self.errors > 0 + } +} + +/// Detect Maven wrapper (./mvnw) in current directory, fall back to mvn. +fn detect_mvn_command() -> &'static str { + if std::path::Path::new("./mvnw").exists() { + "./mvnw" + } else { + "mvn" + } +} + +pub fn run_test(args: &[String], verbose: u8) -> Result<()> { + let timer = tracking::TimedExecution::start(); + + let mvn = detect_mvn_command(); + let mut cmd = Command::new(mvn); + cmd.arg("test"); + + for arg in args { + cmd.arg(arg); + } + + if verbose > 0 { + eprintln!("Running: {} test {}", mvn, args.join(" ")); + } + + let output = cmd + .output() + .with_context(|| format!("Failed to run {} test. Is Maven installed?", mvn))?; + + 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 = output + .status + .code() + .unwrap_or(if output.status.success() { 0 } else { 1 }); + let filtered = filter_mvn_test(&raw); + + if let Some(hint) = crate::tee::tee_and_hint(&raw, "mvn_test", exit_code) { + println!("{}\n{}", filtered, hint); + } else { + println!("{}", filtered); + } + + timer.track( + &format!("mvn test {}", args.join(" ")), + &format!("rtk mvn test {}", args.join(" ")), + &raw, + &filtered, + ); + + if !output.status.success() { + std::process::exit(exit_code); + } + + Ok(()) +} + +pub fn run_other(args: &[OsString], verbose: u8) -> Result<()> { + if args.is_empty() { + anyhow::bail!("mvn: no subcommand specified"); + } + + let timer = tracking::TimedExecution::start(); + + let mvn = detect_mvn_command(); + let subcommand = args[0].to_string_lossy(); + let mut cmd = Command::new(mvn); + for arg in args { + cmd.arg(arg); + } + + if verbose > 0 { + eprintln!("Running: {} {} ...", mvn, subcommand); + } + + let output = cmd + .output() + .with_context(|| format!("Failed to run {} {}", mvn, subcommand))?; + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let raw = format!("{}\n{}", stdout, stderr); + + print!("{}", stdout); + eprint!("{}", stderr); + + timer.track( + &format!("mvn {}", subcommand), + &format!("rtk mvn {}", subcommand), + &raw, + &raw, // No filtering for unsupported subcommands + ); + + if !output.status.success() { + std::process::exit(output.status.code().unwrap_or(1)); + } + + Ok(()) +} + +/// Strip [INFO]/[ERROR]/[WARNING] prefixes from a line. +fn strip_mvn_prefix(line: &str) -> &str { + let trimmed = line.trim(); + if let Some(rest) = trimmed.strip_prefix("[INFO]") { + rest.trim_start() + } else if let Some(rest) = trimmed.strip_prefix("[ERROR]") { + rest.trim_start() + } else if let Some(rest) = trimmed.strip_prefix("[WARNING]") { + rest.trim_start() + } else { + trimmed + } +} + +/// Check if a line is Maven noise that should be stripped. +fn is_noise_line(line: &str) -> bool { + let stripped = strip_mvn_prefix(line); + + // Empty info lines + if stripped.is_empty() { + return true; + } + + // Download lines + if stripped.starts_with("Downloading from") + || stripped.starts_with("Downloaded from") + || stripped.starts_with("Progress (") + { + return true; + } + + // Lifecycle and build boilerplate + if stripped.starts_with("---") && stripped.contains("@") { + return true; // Plugin execution headers like "--- maven-surefire-plugin:3.0:test @ project ---" + } + + // Separator lines + if stripped.chars().all(|c| c == '-' || c == '=') && stripped.len() > 3 { + return true; + } + + // Scanning / building boilerplate + if stripped.starts_with("Scanning for projects") + || stripped.starts_with("Building ") + || stripped.starts_with("Finished at:") + || stripped.starts_with("Final Memory:") + || stripped.starts_with("Using ") + || stripped.starts_with("Reactor Build Order:") + { + return true; + } + + false +} + +/// Parse Maven test output using a state machine. +fn filter_mvn_test(output: &str) -> String { + let mut state = ParseState::Preamble; + let mut total = TestCounts::default(); + let mut class_results: Vec = Vec::new(); + let mut failure_lines: Vec = Vec::new(); + let mut current_failure: Vec = Vec::new(); + let mut build_result = String::new(); + let mut total_time = String::new(); + let mut has_compilation_error = false; + let mut compilation_errors: Vec = Vec::new(); + let mut reactor_lines: Vec = Vec::new(); + let mut in_reactor_summary = false; + + for line in output.lines() { + let trimmed = line.trim(); + let stripped = strip_mvn_prefix(trimmed); + + // Detect module boundaries in multi-module builds + if MODULE_MARKER_RE.is_match(stripped) { + // Reset to preamble for new module, save any pending failure + if !current_failure.is_empty() { + failure_lines.push(current_failure.join("\n")); + current_failure.clear(); + } + state = ParseState::Preamble; + continue; + } + + // Detect Reactor Summary + if stripped == "Reactor Summary:" + || stripped == "Reactor Summary for" + || stripped.starts_with("Reactor Summary") + { + in_reactor_summary = true; + continue; + } + + if in_reactor_summary { + if stripped.is_empty() + || stripped.starts_with("BUILD") + || stripped.starts_with("Total time") + { + in_reactor_summary = false; + // Fall through to process this line + } else { + reactor_lines.push(stripped.to_string()); + continue; + } + } + + // Detect BUILD SUCCESS/FAILURE + if stripped.starts_with("BUILD SUCCESS") || stripped.starts_with("BUILD FAILURE") { + build_result = stripped.to_string(); + state = ParseState::Summary; + continue; + } + + // Detect total time + if let Some(caps) = TOTAL_TIME_RE.captures(stripped) { + if let Some(time_match) = caps.get(1) { + total_time = time_match.as_str().trim().to_string(); + } + continue; + } + + // Detect compilation errors (no test section will follow) + if trimmed.starts_with("[ERROR]") && stripped.contains(".java:") { + has_compilation_error = true; + compilation_errors.push(truncate(stripped, 120).to_string()); + continue; + } + if has_compilation_error && trimmed.starts_with("[ERROR]") && !stripped.is_empty() { + compilation_errors.push(truncate(stripped, 120).to_string()); + continue; + } + + // State transitions + match state { + ParseState::Preamble => { + // Transition to TestSection when we see "Running " + if stripped.starts_with("Running ") { + state = ParseState::TestSection; + class_results.push(stripped.to_string()); + } + // Otherwise skip noise + } + ParseState::TestSection => { + // Track "Running " lines + if stripped.starts_with("Running ") { + class_results.push(stripped.to_string()); + continue; + } + + // Parse per-class test counts + if let Some(caps) = TEST_SUMMARY_RE.captures(stripped) { + let counts = TestCounts { + run: caps[1].parse().unwrap_or(0), + failures: caps[2].parse().unwrap_or(0), + errors: caps[3].parse().unwrap_or(0), + skipped: caps[4].parse().unwrap_or(0), + }; + if counts.has_failures() { + // Keep the class result with failure info + if let Some(last_class) = class_results.last() { + let info = format!( + "{} - {} run, {} failed, {} errors", + last_class, counts.run, counts.failures, counts.errors + ); + let len = class_results.len(); + class_results[len - 1] = info; + } + } + total.accumulate(&counts); + continue; + } + + // Detect failure section + if stripped.contains("<<<") && stripped.contains("FAILURE") { + // Start of an individual test failure + state = ParseState::Failures; + current_failure.push(stripped.to_string()); + continue; + } + + // Detect "Failed tests:" or "Tests in error:" sections + if stripped == "Failed tests:" || stripped == "Tests in error:" { + state = ParseState::Failures; + continue; + } + } + ParseState::Failures => { + // Collect failure details + if stripped.starts_with("Running ") { + // New test class - save current failure and go back to test section + if !current_failure.is_empty() { + failure_lines.push(current_failure.join("\n")); + current_failure.clear(); + } + state = ParseState::TestSection; + class_results.push(stripped.to_string()); + continue; + } + + // Parse test summary that appears within failure context + if TEST_SUMMARY_RE.is_match(stripped) { + if let Some(caps) = TEST_SUMMARY_RE.captures(stripped) { + let counts = TestCounts { + run: caps[1].parse().unwrap_or(0), + failures: caps[2].parse().unwrap_or(0), + errors: caps[3].parse().unwrap_or(0), + skipped: caps[4].parse().unwrap_or(0), + }; + total.accumulate(&counts); + } + if !current_failure.is_empty() { + failure_lines.push(current_failure.join("\n")); + current_failure.clear(); + } + state = ParseState::TestSection; + continue; + } + + // Accumulate failure content + if !stripped.is_empty() && !is_noise_line(line) { + current_failure.push(truncate(stripped, 120).to_string()); + } + } + ParseState::Summary => { + // In summary state, we mostly just need time and build result (already captured) + } + } + } + + // Save any pending failure + if !current_failure.is_empty() { + failure_lines.push(current_failure.join("\n")); + } + + // Handle compilation errors (no tests ran) + if has_compilation_error && total.run == 0 { + return build_compilation_error_output(&compilation_errors, &build_result, &total_time); + } + + // Handle no tests found + if total.run == 0 { + if build_result.contains("FAILURE") { + return format!( + "Mvn test: BUILD FAILURE (no tests ran, {})", + time_display(&total_time) + ); + } + return "Mvn test: No tests found".to_string(); + } + + // Build output + build_test_output( + &total, + &failure_lines, + &reactor_lines, + &build_result, + &total_time, + ) +} + +fn time_display(time: &str) -> String { + if time.is_empty() { + "unknown time".to_string() + } else { + time.to_string() + } +} + +fn build_compilation_error_output( + errors: &[String], + build_result: &str, + total_time: &str, +) -> String { + let mut result = String::new(); + result.push_str(&format!( + "Mvn test: COMPILATION ERROR ({} errors)\n", + errors.len() + )); + result.push_str("---\n"); + + for (i, error) in errors.iter().take(10).enumerate() { + result.push_str(&format!("{}. {}\n", i + 1, error)); + } + + if errors.len() > 10 { + result.push_str(&format!("\n... +{} more errors\n", errors.len() - 10)); + } + + if !build_result.is_empty() { + result.push_str(&format!("\n{}, {}", build_result, time_display(total_time))); + } + + result.trim().to_string() +} + +fn build_test_output( + total: &TestCounts, + failure_lines: &[String], + reactor_lines: &[String], + build_result: &str, + total_time: &str, +) -> String { + let passed = total + .run + .saturating_sub(total.failures + total.errors + total.skipped); + + if !total.has_failures() { + // All pass + let mut msg = format!("ok Mvn test: {} passed", passed); + if total.skipped > 0 { + msg.push_str(&format!(", {} skipped", total.skipped)); + } + if !build_result.is_empty() { + msg.push_str(&format!(" ({})", build_result)); + } + if !total_time.is_empty() { + msg.push_str(&format!(" [{}]", total_time)); + } + return msg; + } + + // Failures present + let mut result = String::new(); + result.push_str(&format!( + "Mvn test: {} run, {} failed, {} errors", + total.run, total.failures, total.errors + )); + if total.skipped > 0 { + result.push_str(&format!(", {} skipped", total.skipped)); + } + result.push('\n'); + + // Show failure details + if !failure_lines.is_empty() { + result.push_str("\nFAILURES:\n"); + for (i, failure) in failure_lines.iter().take(10).enumerate() { + result.push_str(&format!("{}. {}\n", i + 1, failure)); + } + if failure_lines.len() > 10 { + result.push_str(&format!( + "\n... +{} more failures\n", + failure_lines.len() - 10 + )); + } + } + + // Reactor summary for multi-module + if !reactor_lines.is_empty() { + result.push_str("\nReactor:\n"); + for line in reactor_lines { + result.push_str(&format!(" {}\n", line)); + } + } + + if !build_result.is_empty() || !total_time.is_empty() { + result.push_str(&format!("\n{}, {}", build_result, time_display(total_time))); + } + + result.trim().to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn count_tokens(text: &str) -> usize { + text.split_whitespace().count() + } + + #[test] + fn test_filter_mvn_test_all_pass() { + let input = r#"[INFO] Scanning for projects... +[INFO] +[INFO] ----------------------< com.example:my-app >---------------------- +[INFO] Building my-app 1.0-SNAPSHOT +[INFO] --------------------------------[ jar ]--------------------------------- +[INFO] +[INFO] --- maven-resources-plugin:3.3.0:resources (default-resources) @ my-app --- +[INFO] Using 'UTF-8' encoding to copy filtered resources. +[INFO] +[INFO] --- maven-compiler-plugin:3.11.0:compile (default-compile) @ my-app --- +[INFO] Nothing to compile - all classes are up to date +[INFO] +[INFO] --- maven-surefire-plugin:3.1.2:test (default-test) @ my-app --- +[INFO] Using auto detected provider org.apache.maven.surefire.junitplatform.JUnitPlatformProvider +[INFO] +[INFO] ------------------------------------------------------- +[INFO] T E S T S +[INFO] ------------------------------------------------------- +[INFO] Running com.example.AppTest +[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.042 s -- in com.example.AppTest +[INFO] Running com.example.UtilsTest +[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.018 s -- in com.example.UtilsTest +[INFO] +[INFO] Results: +[INFO] +[INFO] Tests run: 8, Failures: 0, Errors: 0, Skipped: 0 +[INFO] +[INFO] ------------------------------------------------------------------------ +[INFO] BUILD SUCCESS +[INFO] ------------------------------------------------------------------------ +[INFO] Total time: 5.123 s +[INFO] Finished at: 2026-02-23T10:00:00Z +[INFO] Final Memory: 42M/512M +[INFO] ------------------------------------------------------------------------"#; + + let result = filter_mvn_test(input); + assert!( + result.contains("ok Mvn test"), + "Expected success marker, got: {}", + result + ); + assert!( + result.contains("passed"), + "Expected 'passed', got: {}", + result + ); + assert!( + result.contains("BUILD SUCCESS"), + "Expected BUILD SUCCESS, got: {}", + result + ); + // Should NOT contain noise + assert!( + !result.contains("Scanning for projects"), + "Should strip preamble" + ); + assert!( + !result.contains("maven-surefire-plugin"), + "Should strip plugin headers" + ); + assert!(!result.contains("Final Memory"), "Should strip memory info"); + } + + #[test] + fn test_filter_mvn_test_with_failures() { + let input = r#"[INFO] Scanning for projects... +[INFO] +[INFO] ----------------------< com.example:my-app >---------------------- +[INFO] Building my-app 1.0-SNAPSHOT +[INFO] --------------------------------[ jar ]--------------------------------- +[INFO] --- maven-surefire-plugin:3.1.2:test (default-test) @ my-app --- +[INFO] Running com.example.FooTest +[ERROR] Tests run: 3, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.5 s <<< FAILURE! -- in com.example.FooTest +[ERROR] com.example.FooTest.testAdd Time elapsed: 0.002 s <<< FAILURE! +org.opentest4j.AssertionFailedError: expected: <15> but was: <14> + at org.junit.jupiter.api.AssertionUtils.fail(AssertionUtils.java:55) + at com.example.FooTest.testAdd(FooTest.java:45) +[INFO] Running com.example.BarTest +[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.1 s -- in com.example.BarTest +[INFO] +[INFO] Results: +[INFO] +[ERROR] Failures: +[ERROR] FooTest.testAdd:45 expected: <15> but was: <14> +[INFO] +[ERROR] Tests run: 8, Failures: 1, Errors: 0, Skipped: 0 +[INFO] +[INFO] ------------------------------------------------------------------------ +[INFO] BUILD FAILURE +[INFO] ------------------------------------------------------------------------ +[INFO] Total time: 3.456 s +[INFO] Finished at: 2026-02-23T10:00:00Z +[INFO] ------------------------------------------------------------------------"#; + + let result = filter_mvn_test(input); + assert!( + result.contains("failed"), + "Expected failure count, got: {}", + result + ); + assert!( + result.contains("FAILURE"), + "Expected FAILURES section, got: {}", + result + ); + assert!( + result.contains("expected:"), + "Expected assertion message, got: {}", + result + ); + assert!( + result.contains("BUILD FAILURE"), + "Expected BUILD FAILURE, got: {}", + result + ); + } + + #[test] + fn test_filter_mvn_test_strips_downloads() { + let input = r#"[INFO] Scanning for projects... +Downloading from central: https://repo.maven.apache.org/maven2/org/apache/maven/plugins/maven-surefire-plugin/3.1.2/maven-surefire-plugin-3.1.2.pom +Downloaded from central: https://repo.maven.apache.org/maven2/org/apache/maven/plugins/maven-surefire-plugin/3.1.2/maven-surefire-plugin-3.1.2.pom (5.2 kB at 120 kB/s) +Downloading from central: https://repo.maven.apache.org/maven2/junit/junit/4.13.2/junit-4.13.2.jar +Downloaded from central: https://repo.maven.apache.org/maven2/junit/junit/4.13.2/junit-4.13.2.jar (384 kB at 2.1 MB/s) +[INFO] --- maven-surefire-plugin:3.1.2:test (default-test) @ my-app --- +[INFO] Running com.example.AppTest +[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.01 s -- in com.example.AppTest +[INFO] BUILD SUCCESS +[INFO] Total time: 12.345 s"#; + + let result = filter_mvn_test(input); + assert!( + !result.contains("Downloading"), + "Should strip download lines" + ); + assert!( + !result.contains("Downloaded"), + "Should strip downloaded lines" + ); + assert!( + result.contains("ok Mvn test"), + "Expected success, got: {}", + result + ); + } + + #[test] + fn test_filter_mvn_test_no_tests() { + let input = r#"[INFO] Scanning for projects... +[INFO] BUILD SUCCESS +[INFO] Total time: 0.5 s"#; + + let result = filter_mvn_test(input); + assert!( + result.contains("No tests found"), + "Expected no tests message, got: {}", + result + ); + } + + #[test] + fn test_filter_mvn_test_compilation_error() { + let input = r#"[INFO] Scanning for projects... +[INFO] --- maven-compiler-plugin:3.11.0:compile (default-compile) @ my-app --- +[ERROR] /src/main/java/com/example/App.java:[15,10] cannot find symbol +[ERROR] symbol: variable foo +[ERROR] location: class com.example.App +[ERROR] /src/main/java/com/example/App.java:[22,5] ';' expected +[INFO] BUILD FAILURE +[INFO] Total time: 1.234 s"#; + + let result = filter_mvn_test(input); + assert!( + result.contains("COMPILATION ERROR"), + "Expected compilation error, got: {}", + result + ); + assert!( + result.contains("cannot find symbol"), + "Expected error detail, got: {}", + result + ); + assert!( + result.contains("BUILD FAILURE"), + "Expected BUILD FAILURE, got: {}", + result + ); + } + + #[test] + fn test_filter_mvn_test_multiple_classes() { + let input = r#"[INFO] --- maven-surefire-plugin:3.1.2:test (default-test) @ my-app --- +[INFO] Running com.example.AlphaTest +[INFO] Tests run: 10, Failures: 0, Errors: 0, Skipped: 2, Time elapsed: 0.5 s -- in com.example.AlphaTest +[INFO] Running com.example.BetaTest +[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.3 s -- in com.example.BetaTest +[INFO] Running com.example.GammaTest +[INFO] Tests run: 8, Failures: 0, Errors: 0, Skipped: 1, Time elapsed: 0.2 s -- in com.example.GammaTest +[INFO] Tests run: 23, Failures: 0, Errors: 0, Skipped: 3 +[INFO] BUILD SUCCESS +[INFO] Total time: 4.5 s"#; + + let result = filter_mvn_test(input); + assert!( + result.contains("ok Mvn test"), + "Expected success, got: {}", + result + ); + // The accumulator should count all tests + assert!( + result.contains("skipped"), + "Expected skipped count, got: {}", + result + ); + } + + #[test] + fn test_mvn_test_token_savings() { + let input = r#"[INFO] Scanning for projects... +[INFO] +[INFO] ----------------------< com.example:my-app >---------------------- +[INFO] Building my-app 1.0-SNAPSHOT +[INFO] --------------------------------[ jar ]--------------------------------- +Downloading from central: https://repo.maven.apache.org/maven2/org/apache/maven/plugins/maven-resources-plugin/3.3.0/maven-resources-plugin-3.3.0.pom +Downloaded from central: https://repo.maven.apache.org/maven2/org/apache/maven/plugins/maven-resources-plugin/3.3.0/maven-resources-plugin-3.3.0.pom (8.2 kB at 180 kB/s) +Downloading from central: https://repo.maven.apache.org/maven2/org/apache/maven/plugins/maven-compiler-plugin/3.11.0/maven-compiler-plugin-3.11.0.pom +Downloaded from central: https://repo.maven.apache.org/maven2/org/apache/maven/plugins/maven-compiler-plugin/3.11.0/maven-compiler-plugin-3.11.0.pom (12 kB at 250 kB/s) +Downloading from central: https://repo.maven.apache.org/maven2/org/apache/maven/plugins/maven-surefire-plugin/3.1.2/maven-surefire-plugin-3.1.2.pom +Downloaded from central: https://repo.maven.apache.org/maven2/org/apache/maven/plugins/maven-surefire-plugin/3.1.2/maven-surefire-plugin-3.1.2.pom (5.2 kB at 120 kB/s) +Downloading from central: https://repo.maven.apache.org/maven2/junit/junit/4.13.2/junit-4.13.2.jar +Downloaded from central: https://repo.maven.apache.org/maven2/junit/junit/4.13.2/junit-4.13.2.jar (384 kB at 2.1 MB/s) +Downloading from central: https://repo.maven.apache.org/maven2/org/hamcrest/hamcrest-core/1.3/hamcrest-core-1.3.jar +Downloaded from central: https://repo.maven.apache.org/maven2/org/hamcrest/hamcrest-core/1.3/hamcrest-core-1.3.jar (45 kB at 900 kB/s) +[INFO] +[INFO] --- maven-resources-plugin:3.3.0:resources (default-resources) @ my-app --- +[INFO] Using 'UTF-8' encoding to copy filtered resources. +[INFO] Copying 1 resource from src/main/resources to target/classes +[INFO] +[INFO] --- maven-compiler-plugin:3.11.0:compile (default-compile) @ my-app --- +[INFO] Nothing to compile - all classes are up to date +[INFO] +[INFO] --- maven-resources-plugin:3.3.0:testResources (default-testResources) @ my-app --- +[INFO] Using 'UTF-8' encoding to copy filtered resources. +[INFO] skip non existing resourceDirectory /home/user/my-app/src/test/resources +[INFO] +[INFO] --- maven-compiler-plugin:3.11.0:testCompile (default-testCompile) @ my-app --- +[INFO] Nothing to compile - all classes are up to date +[INFO] +[INFO] --- maven-surefire-plugin:3.1.2:test (default-test) @ my-app --- +[INFO] Using auto detected provider org.apache.maven.surefire.junitplatform.JUnitPlatformProvider +[INFO] +[INFO] ------------------------------------------------------- +[INFO] T E S T S +[INFO] ------------------------------------------------------- +[INFO] Running com.example.AppTest +[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.042 s -- in com.example.AppTest +[INFO] Running com.example.ServiceTest +[INFO] Tests run: 12, Failures: 0, Errors: 0, Skipped: 1, Time elapsed: 0.128 s -- in com.example.ServiceTest +[INFO] Running com.example.RepositoryTest +[INFO] Tests run: 8, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.256 s -- in com.example.RepositoryTest +[INFO] Running com.example.ControllerTest +[INFO] Tests run: 15, Failures: 0, Errors: 0, Skipped: 2, Time elapsed: 0.512 s -- in com.example.ControllerTest +[INFO] Running com.example.UtilsTest +[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.018 s -- in com.example.UtilsTest +[INFO] +[INFO] Results: +[INFO] +[INFO] Tests run: 43, Failures: 0, Errors: 0, Skipped: 3 +[INFO] +[INFO] ------------------------------------------------------------------------ +[INFO] BUILD SUCCESS +[INFO] ------------------------------------------------------------------------ +[INFO] Total time: 8.234 s +[INFO] Finished at: 2026-02-23T10:15:30Z +[INFO] Final Memory: 42M/512M +[INFO] ------------------------------------------------------------------------"#; + + let result = filter_mvn_test(input); + let input_tokens = count_tokens(input); + let output_tokens = count_tokens(&result); + let savings = 100.0 - (output_tokens as f64 / input_tokens as f64 * 100.0); + + assert!( + savings >= 75.0, + "Mvn test filter: expected >=75% savings, got {:.1}% (input: {} tokens, output: {} tokens)\nOutput: {}", + savings, input_tokens, output_tokens, result + ); + } + + #[test] + fn test_parse_test_summary_line() { + let line = "Tests run: 42, Failures: 2, Errors: 1, Skipped: 3"; + let caps = TEST_SUMMARY_RE.captures(line).expect("Should match"); + assert_eq!(&caps[1], "42"); + assert_eq!(&caps[2], "2"); + assert_eq!(&caps[3], "1"); + assert_eq!(&caps[4], "3"); + } + + #[test] + fn test_is_noise_line_downloads() { + assert!(is_noise_line( + "Downloading from central: https://repo.maven.apache.org/..." + )); + assert!(is_noise_line( + "Downloaded from central: https://repo.maven.apache.org/..." + )); + } + + #[test] + fn test_is_noise_line_lifecycle() { + assert!(is_noise_line( + "[INFO] --- maven-surefire-plugin:3.1.2:test @ my-app ---" + )); + assert!(is_noise_line("[INFO] Scanning for projects...")); + assert!(is_noise_line("[INFO] Finished at: 2026-02-23T10:00:00Z")); + assert!(is_noise_line("[INFO] Final Memory: 42M/512M")); + } + + #[test] + fn test_is_noise_line_not_noise() { + assert!(!is_noise_line("[INFO] Running com.example.AppTest")); + assert!(!is_noise_line( + "[ERROR] Tests run: 3, Failures: 1, Errors: 0, Skipped: 0" + )); + assert!(!is_noise_line("[INFO] BUILD SUCCESS")); + } + + #[test] + fn test_strip_mvn_prefix() { + assert_eq!(strip_mvn_prefix("[INFO] Running test"), "Running test"); + assert_eq!( + strip_mvn_prefix("[ERROR] Something failed"), + "Something failed" + ); + assert_eq!(strip_mvn_prefix("[WARNING] Deprecated"), "Deprecated"); + assert_eq!(strip_mvn_prefix("plain line"), "plain line"); + } +} From 5a646ebb6859f786512d13de0de0221d99a2fc9f Mon Sep 17 00:00:00 2001 From: dlmw <12473240+dlmw@users.noreply.github.com> Date: Tue, 24 Feb 2026 07:22:39 +0100 Subject: [PATCH 2/2] Update ARCHITECTURE.md --- ARCHITECTURE.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 7d63d3c8..31b53a66 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -224,7 +224,7 @@ Database: ~/.local/share/rtk/history.db ## Module Organization -### Complete Module Map (30 Modules) +### Complete Module Map (31 Modules) ``` ┌────────────────────────────────────────────────────────────────────────┐ @@ -272,6 +272,8 @@ PYTHON ruff_cmd.rs ruff check/format 80%+ ✓ GO go_cmd.rs go test/build/vet 75-90% ✓ golangci_cmd.rs golangci-lint 85% ✓ +JAVA/JVM mvn_cmd.rs mvn test 99% ✓ + NETWORK wget_cmd.rs wget 85-95% ✓ DEPENDENCIES deps.rs deps 80-90% ✓ @@ -288,16 +290,17 @@ SHARED utils.rs Helpers N/A ✓ tee.rs Full output recovery N/A ✓ ``` -**Total: 50 modules** (32 command modules + 18 infrastructure modules) +**Total: 51 modules** (33 command modules + 18 infrastructure modules) ### Module Count Breakdown -- **Command Modules**: 31 (directly exposed to users) +- **Command Modules**: 32 (directly exposed to users) - **Infrastructure Modules**: 18 (utils, filter, tracking, tee, config, init, gain, etc.) - **Git Commands**: 7 operations (status, diff, log, add, commit, push, branch/checkout) - **JS/TS Tooling**: 8 modules (modern frontend/fullstack development) - **Python Tooling**: 3 modules (ruff, pytest, pip) - **Go Tooling**: 2 modules (go test/build/vet, golangci-lint) +- **Java/JVM Tooling**: 1 module (mvn test with passthrough for other subcommands) --- @@ -385,7 +388,7 @@ Strategy Modules Technique Reduction │ Live updates │ Final result └──────────────┘ - Used by: wget, pnpm install (strip ANSI escape sequences) + Used by: wget, pnpm install, mvn (strip ANSI escape sequences, download progress) 10. JSON/TEXT DUAL MODE ┌──────────────┐ @@ -401,7 +404,7 @@ Strategy Modules Technique Reduction │ Mixed format │ Extract failures Failure details └──────────────┘ - Used by: pytest (text state machine: test_name → PASSED/FAILED) + Used by: pytest (text state machine: test_name → PASSED/FAILED), mvn test (preamble → test section → failures → summary) 12. NDJSON STREAMING ┌──────────────┐ @@ -1481,6 +1484,6 @@ When implementing a new command, consider: --- -**Last Updated**: 2026-02-22 -**Architecture Version**: 2.2 +**Last Updated**: 2026-02-24 +**Architecture Version**: 2.3 **rtk Version**: 0.22.2