diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..fd52a2695 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +# Fixtures are byte-exact filter inputs; eol conversion must never touch them. +# Without this, Windows runners (core.autocrlf=true) check fixtures out with +# CRLF, and tests that synthesize CRLF input from them produce \r\r\n. +tests/fixtures/** -text diff --git a/src/cmds/cloud/aws_cmd.rs b/src/cmds/cloud/aws_cmd.rs index 64c18cf7c..833d2fe7b 100644 --- a/src/cmds/cloud/aws_cmd.rs +++ b/src/cmds/cloud/aws_cmd.rs @@ -215,7 +215,7 @@ fn is_structured_operation(args: &[String]) -> bool { || op == "receive-message" } -/// Generic strategy: force --output json for structured ops, compress via json_cmd schema +/// Generic strategy: force --output json for structured ops, compress via json_cmd compact (values preserved) fn run_generic(subcommand: &str, args: &[String], verbose: u8, full_sub: &str) -> Result { let timer = tracking::TimedExecution::start(); @@ -256,10 +256,10 @@ fn run_generic(subcommand: &str, args: &[String], verbose: u8, full_sub: &str) - return Ok(crate::core::utils::exit_code_from_output(&output, "aws")); } - let filtered = match json_cmd::filter_json_string(&raw, JSON_COMPRESS_DEPTH) { - Ok(schema) => { - println!("{}", schema); - schema + let filtered = match json_cmd::filter_json_compact(&raw, JSON_COMPRESS_DEPTH) { + Ok(compact) => { + println!("{}", compact); + compact } Err(_) => { // Fallback: print raw (maybe not JSON) @@ -2749,4 +2749,29 @@ upload: file10.txt to s3://bucket/file10.txt // Should report all 30 failures, not capped at MAX_ITEMS (20) assert!(result.text.contains("30 failed")); } + + // Regression: generic AWS path (unsupported subcommand returning JSON) must + // compress responses while preserving values, not collapse them to schema + // type names. Calls the primitive used at aws_cmd.rs run_generic line 259. + #[test] + fn test_aws_unsupported_subcommand_json_preserves_values() { + let fixture = include_str!( + "../../../tests/fixtures/aws_backup_describe_global_settings.json" + ); + let output = json_cmd::filter_json_compact(fixture, JSON_COMPRESS_DEPTH) + .expect("filter_json_compact must not error on valid AWS JSON"); + + assert!( + output.contains("\"false\""), + "values must be preserved (expected literal \"false\"), got:\n{output}" + ); + assert!( + !output.contains(": string"), + "schema-type leakage detected (\": string\" found), got:\n{output}" + ); + assert!( + output.contains("isMpaEnabled"), + "object keys must be preserved, got:\n{output}" + ); + } } diff --git a/src/cmds/cloud/curl_cmd.rs b/src/cmds/cloud/curl_cmd.rs index ed5b04a86..7073c2e42 100644 --- a/src/cmds/cloud/curl_cmd.rs +++ b/src/cmds/cloud/curl_cmd.rs @@ -5,13 +5,18 @@ //! The condensed-form-with-tee-hint path is reserved for non-JSON bodies on //! a real terminal where a human reads the output and the tee file gives the //! LLM a way to recover the raw response. +//! +//! Binary downloads (any non-UTF-8 byte sequence) are written through to +//! stdout as raw bytes, bypassing the UTF-8 lossy conversion that would +//! otherwise replace non-UTF-8 bytes with U+FFFD and corrupt the stream +//! (`#1087`). use crate::core::tee::force_tee_hint; use crate::core::tracking; -use crate::core::{stream::exec_capture, utils::resolved_command}; +use crate::core::utils::resolved_command; use anyhow::{Context, Result}; use std::borrow::Cow; -use std::io::IsTerminal; +use std::io::{IsTerminal, Write}; const MAX_RESPONSE_SIZE: usize = 500; @@ -28,22 +33,46 @@ pub fn run(args: &[String], verbose: u8) -> Result { eprintln!("Running: curl -s {}", args.join(" ")); } - let result = exec_capture(&mut cmd).context("Failed to run curl")?; + // Capture stdout as raw bytes (not UTF-8 String) so binary downloads + // survive intact. `String::from_utf8_lossy` would otherwise replace + // every non-UTF-8 byte with U+FFFD (3 bytes), corrupting e.g. gzip + // magic `1f 8b 08 00` into `1f ef bf bd 08 00` (#1087). + let output = cmd.output().context("Failed to run curl")?; + let exit_code = output.status.code().unwrap_or(1); // Skip filtering on failure: curl can return HTML error bodies that would // be misleading to summarize, and we want the real exit code surfaced. - if !result.success() { - let msg = if result.stderr.trim().is_empty() { - result.stdout.trim().to_string() + if !output.status.success() { + let stderr_str = String::from_utf8_lossy(&output.stderr); + let stdout_str = String::from_utf8_lossy(&output.stdout); + let msg = if stderr_str.trim().is_empty() { + stdout_str.trim().to_string() } else { - result.stderr.trim().to_string() + stderr_str.trim().to_string() }; eprintln!("FAILED: curl {}", msg); - return Ok(result.exit_code); + return Ok(exit_code); } - let exit_code = result.exit_code; - let raw = result.stdout; + // Binary detection: if the body is not valid UTF-8, `from_utf8_lossy` + // would replace every invalid byte with U+FFFD and corrupt the stream + // (gzip, zip, png, pdf, elf, ... — any binary format). Write raw bytes + // through and skip filtering. Tracking is recorded as passthrough + // (0% savings) since token counts over binary content have no meaning. + if is_binary(&output.stdout) { + let stdout = std::io::stdout(); + let mut handle = stdout.lock(); + handle + .write_all(&output.stdout) + .context("Failed to write binary response to stdout")?; + timer.track_passthrough( + &format!("curl {}", args.join(" ")), + &format!("rtk curl {}", args.join(" ")), + ); + return Ok(exit_code); + } + + let raw = String::from_utf8_lossy(&output.stdout).into_owned(); let is_tty = std::io::stdout().is_terminal(); let filtered = filter_curl_output(&raw, is_tty); @@ -62,6 +91,17 @@ pub fn run(args: &[String], verbose: u8) -> Result { Ok(exit_code) } +/// Returns `true` if `bytes` is not valid UTF-8 — which is exactly the +/// condition under which `from_utf8_lossy` would replace invalid bytes +/// with U+FFFD and corrupt downstream consumers (`#1087`). +/// +/// This is correct by construction: the only reason to passthrough raw +/// bytes is to avoid the lossy conversion, and the only bytes that suffer +/// from it are the non-UTF-8 ones. +fn is_binary(bytes: &[u8]) -> bool { + std::str::from_utf8(bytes).is_err() +} + fn filter_curl_output(raw: &str, is_tty: bool) -> FilterResult<'_> { let trimmed = raw.trim(); @@ -239,4 +279,35 @@ mod tests { let json_result = filter_curl_output(&json_payload, true); assert!(matches!(json_result.content, Cow::Borrowed(_))); } + + // --- is_binary tests ---------------------------------------------------- + + #[test] + fn test_is_binary_gzip_magic_is_not_utf8() { + // gzip magic 1f 8b — 0x8b is an invalid UTF-8 continuation byte + let bytes = [0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03]; + assert!(is_binary(&bytes)); + } + + #[test] + fn test_is_binary_valid_utf8_text_is_not_binary() { + assert!(!is_binary(br#"{"key": "value"}"#)); + assert!(!is_binary(b"\nHi")); + assert!(!is_binary(b"Plain ASCII text")); + assert!(!is_binary("Héllo wörld — emojis 🚀 ✓".as_bytes())); + } + + #[test] + fn test_is_binary_empty_is_not_binary() { + // Empty input is technically valid UTF-8 and trivially safe to filter. + assert!(!is_binary(&[])); + } + + #[test] + fn test_is_binary_text_with_nul_is_not_binary() { + // NUL is valid UTF-8 (U+0000). Unusual in HTTP responses but the + // function honors UTF-8 strictly — caller can still filter such + // content safely. The bug we're fixing is only invalid UTF-8 bytes. + assert!(!is_binary(b"text with\0embedded nul")); + } } diff --git a/src/cmds/cloud/wget_cmd.rs b/src/cmds/cloud/wget_cmd.rs index 4faed8081..09e52f24b 100644 --- a/src/cmds/cloud/wget_cmd.rs +++ b/src/cmds/cloud/wget_cmd.rs @@ -78,7 +78,7 @@ pub fn run_stdout(url: &str, args: &[String], verbose: u8) -> Result { total, format_size(result.stdout.len() as u64) )); - rtk_output.push_str("--- first 10 lines ---\n"); + rtk_output.push_str("first 10 lines:\n"); for line in lines.iter().take(10) { rtk_output.push_str(&format!("{}\n", truncate_line(line, 100))); } diff --git a/src/cmds/dotnet/dotnet_cmd.rs b/src/cmds/dotnet/dotnet_cmd.rs index d16f8bc6f..f90db8b02 100644 --- a/src/cmds/dotnet/dotnet_cmd.rs +++ b/src/cmds/dotnet/dotnet_cmd.rs @@ -368,7 +368,6 @@ fn format_dotnet_format_output( } let mut output = format!("Format: {} files need formatting", changed_count); - output.push_str("\n---------------------------------------"); const MAX_FORMAT_FILES: usize = CAP_LIST; for (index, file) in summary @@ -1056,12 +1055,6 @@ fn format_build_output(summary: &binlog::BuildSummary, _binlog_path: &Path) -> S } } - let sep = if !warnings.is_empty() || !errors.is_empty() { - "---------------------------------------" - } else { - "" - }; - let verdict = format!( "{} dotnet build: {} projects, {} errors, {} warnings ({})", status_icon, @@ -1076,7 +1069,7 @@ fn format_build_output(summary: &binlog::BuildSummary, _binlog_path: &Path) -> S // definitive verdict. Mirrors native `dotnet build`, which ends with // `Build succeeded.` / `Build FAILED.`. See issue #1574. // Warnings before errors: errors survive `| tail -N` immediately above the verdict. - [warnings, errors, sep.into(), verdict] + [warnings, errors, verdict] .into_iter() .filter(|s| !s.is_empty()) .collect::>() @@ -1218,24 +1211,9 @@ fn format_test_output( } } - let sep = if !failed_tests_section.is_empty() - || !warnings_section.is_empty() - || !errors_section.is_empty() - { - "---------------------------------------" - } else { - "" - }; - // Status line emitted last; see format_build_output (issue #1574). // Warnings before errors: errors survive `| tail -N` immediately above the verdict. - [ - failed_tests_section, - warnings_section, - errors_section, - sep.into(), - header, - ] + [failed_tests_section, warnings_section, errors_section, header] .into_iter() .filter(|s| !s.is_empty()) .collect::>() @@ -1311,12 +1289,6 @@ fn format_restore_output( } } - let sep = if !warnings_section.is_empty() || !errors_section.is_empty() { - "---------------------------------------" - } else { - "" - }; - let verdict = format!( "{} dotnet restore: {} projects, {} errors, {} warnings ({})", status_icon, summary.restored_projects, summary.errors, summary.warnings, duration @@ -1324,7 +1296,7 @@ fn format_restore_output( // Status line emitted last; see format_build_output (issue #1574). // Warnings before errors: errors survive `| tail -N` immediately above the verdict. - [warnings_section, errors_section, sep.into(), verdict] + [warnings_section, errors_section, verdict] .into_iter() .filter(|s| !s.is_empty()) .collect::>() diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index 8ac6a5aaf..29fd3b73a 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -193,10 +193,10 @@ fn run_diff( let mut final_output = result.stdout.clone(); if !diff_result.stdout.is_empty() { - println!("\n--- Changes ---"); + println!("\nChanges:"); let compacted = compact_diff(&diff_result.stdout, max_lines.unwrap_or(500)); println!("{}", compacted); - final_output.push_str("\n--- Changes ---\n"); + final_output.push_str("\nChanges:\n"); final_output.push_str(&compacted); } @@ -305,7 +305,7 @@ fn run_show( let mut final_output = summary_result.stdout.clone(); if !diff_text.is_empty() { if verbose > 0 { - println!("\n--- Changes ---"); + println!("\nChanges:"); } let compacted = compact_diff(diff_text, max_lines.unwrap_or(500)); println!("{}", compacted); diff --git a/src/cmds/go/go_cmd.rs b/src/cmds/go/go_cmd.rs index a39e8bb1b..fcc59df6b 100644 --- a/src/cmds/go/go_cmd.rs +++ b/src/cmds/go/go_cmd.rs @@ -431,7 +431,6 @@ pub(crate) fn filter_go_test_json(output: &str) -> String { result.push_str(&format!(", {} skipped", total_skip)); } result.push_str(&format!(" in {} packages\n", total_packages)); - result.push_str("═══════════════════════════════════════\n"); // Show package-level failures first (timeouts, signals, panics). // Skip packages that already have individual test-level failures — those are displayed @@ -594,7 +593,6 @@ fn filter_go_build_with_exit(output: &str, exit_code: i32) -> String { let mut result = String::new(); result.push_str(&format!("Go build: {} errors\n", errors.len())); - result.push_str("═══════════════════════════════════════\n"); const MAX_GO_BUILD_ERRORS: usize = CAP_ERRORS; for (i, error) in errors.iter().take(MAX_GO_BUILD_ERRORS).enumerate() { @@ -718,7 +716,6 @@ fn filter_go_vet(output: &str) -> String { let mut result = String::new(); result.push_str(&format!("Go vet: {} issues\n", issues.len())); - result.push_str("═══════════════════════════════════════\n"); const MAX_GO_VET_ISSUES: usize = CAP_ERRORS; for (i, issue) in issues.iter().take(MAX_GO_VET_ISSUES).enumerate() { diff --git a/src/cmds/go/golangci_cmd.rs b/src/cmds/go/golangci_cmd.rs index 8c9ef8427..865b9b8b0 100644 --- a/src/cmds/go/golangci_cmd.rs +++ b/src/cmds/go/golangci_cmd.rs @@ -310,7 +310,6 @@ pub(crate) fn filter_golangci_json(output: &str, version: u32) -> String { "golangci-lint: {} issues in {} files\n", total_issues, total_files )); - result.push_str("═══════════════════════════════════════\n"); // Show top linters let mut linter_counts: Vec<_> = by_linter.iter().collect(); diff --git a/src/cmds/js/lint_cmd.rs b/src/cmds/js/lint_cmd.rs index 13fa95464..afc3e7962 100644 --- a/src/cmds/js/lint_cmd.rs +++ b/src/cmds/js/lint_cmd.rs @@ -268,7 +268,6 @@ fn filter_eslint_json(output: &str) -> String { "ESLint: {} errors, {} warnings in {} files\n", total_errors, total_warnings, total_files )); - result.push_str("═══════════════════════════════════════\n"); // Show top rules let mut rule_counts: Vec<_> = by_rule.iter().collect(); @@ -394,7 +393,6 @@ fn filter_pylint_json(output: &str) -> String { result.push('\n'); } - result.push_str("═══════════════════════════════════════\n"); // Show top symbols (rules) let mut symbol_counts: Vec<_> = by_symbol.iter().collect(); @@ -471,7 +469,6 @@ fn filter_generic_lint(output: &str) -> String { let mut result = String::new(); result.push_str(&format!("Lint: {} errors, {} warnings\n", errors, warnings)); - result.push_str("═══════════════════════════════════════\n"); const MAX_ISSUES: usize = CAP_ERRORS; for issue in issues.iter().take(MAX_ISSUES) { diff --git a/src/cmds/js/next_cmd.rs b/src/cmds/js/next_cmd.rs index 1f15a2af0..fd359f0a0 100644 --- a/src/cmds/js/next_cmd.rs +++ b/src/cmds/js/next_cmd.rs @@ -115,7 +115,6 @@ fn filter_next_build(output: &str) -> String { // Build filtered output let mut result = String::new(); result.push_str("Next.js Build\n"); - result.push_str("═══════════════════════════════════════\n"); if already_built && routes_total == 0 { result.push_str("Already built (using cache)\n\n"); diff --git a/src/cmds/js/prettier_cmd.rs b/src/cmds/js/prettier_cmd.rs index 685b2f51d..54ef74064 100644 --- a/src/cmds/js/prettier_cmd.rs +++ b/src/cmds/js/prettier_cmd.rs @@ -94,7 +94,6 @@ pub fn filter_prettier_output(output: &str) -> String { "Prettier: {} files need formatting\n", files_to_format.len() )); - result.push_str("═══════════════════════════════════════\n"); const MAX_PRETTIER_FILES: usize = CAP_WARNINGS; for (i, file) in files_to_format.iter().take(MAX_PRETTIER_FILES).enumerate() { diff --git a/src/cmds/js/prisma_cmd.rs b/src/cmds/js/prisma_cmd.rs index 397236907..2f7231382 100644 --- a/src/cmds/js/prisma_cmd.rs +++ b/src/cmds/js/prisma_cmd.rs @@ -277,7 +277,6 @@ fn filter_migrate_dev(output: &str) -> String { if !migration_name.is_empty() { result.push_str(&format!("Migration: {}\n", migration_name)); - result.push_str("═══════════════════════════════════════\n"); } result.push_str("Changes:\n"); diff --git a/src/cmds/js/tsc_cmd.rs b/src/cmds/js/tsc_cmd.rs index 0b75fc9f6..96a494cd8 100644 --- a/src/cmds/js/tsc_cmd.rs +++ b/src/cmds/js/tsc_cmd.rs @@ -84,7 +84,7 @@ impl BlockHandler for TscHandler { } let mut result = format!( - "═══════════════════════════════════════\nTypeScript: {} errors in {} files\n", + "TypeScript: {} errors in {} files\n", self.error_count, self.files.len() ); @@ -174,7 +174,6 @@ pub(crate) fn filter_tsc_output(output: &str) -> String { errors.len(), by_file.len() )); - result.push_str("═══════════════════════════════════════\n"); // Top error codes summary (compact, one line) let mut code_counts: Vec<_> = by_code.iter().collect(); diff --git a/src/cmds/jvm/README.md b/src/cmds/jvm/README.md new file mode 100644 index 000000000..0fe2e0d40 --- /dev/null +++ b/src/cmds/jvm/README.md @@ -0,0 +1,42 @@ +# JVM ecosystem filters + +Filters for JVM-based build tools. + +| Module | Tool(s) | Modes | +|------------------|--------------------------------------|----------------------------------------------------------------------------------------| +| `gradlew_cmd.rs` | `./gradlew`, `gradlew.bat`, `gradle` | Build / Test / ConnectedTest / Lint / Dependencies — streaming line filter + passthrough | +| `mvn_cmd.rs` | `mvn`, `./mvnw`, `mvnw.cmd` | Test / Compile / Package / Passthrough — buffered single-pass filter per phase | + +## Maven (`mvn_cmd.rs`) + +Phase routing (`detect_phase`): + +| Phase | Goals | Filter | +|-------------|--------------------------------------------------------|-------------------------| +| `Test` | `test`, `integration-test` (Failsafe = Surefire shape) | `filter_surefire` | +| `Compile` | `compile`, `test-compile` | `filter_compile` | +| `Package` | `package`, `install`, `verify`, `deploy` | `filter_package` | +| `Passthrough` | `clean`, `site`, `dependency:*`, `--version`, `--help`, empty, any unrecognised goal | none | + +Key behaviours: + +- **ANSI strip first** in every filter — real Maven output contains colour escapes. +- **English-footer guard** — if neither `BUILD SUCCESS` nor `BUILD FAILURE` appears as a trimmed line suffix, return the ANSI-stripped raw input unchanged. Protects non-English locales. +- **Verbose bypass** — `-X`, `--debug`, `-e`, `--errors` skip filtering (`run_passthrough`). User asked for detail; respect it. +- **Surefire block collapse** — Surefire emits `[INFO] Running ` … `[INFO] Tests run: N, Failures: F, Errors: E, …, Time elapsed: T s - in `. The filter buffers each block and emits it only when `F > 0` or `E > 0`. Passing blocks (the bulk of healthy-project output) are dropped silently. Failing blocks are emitted with framework stack frames stripped via a deny-list (`at org.junit.`, `at java.util.`, `at sun.reflect.`, etc.). +- **Multi-failure classes (trail re-arm)** — when a single class has several failing tests, Surefire 3.x emits one blank-separated detail block per failing test under a single close line. When a failure trail ends at a blank line, the state machine arms a re-entry: the next per-test subline (`[ERROR] FQN.method -- Time elapsed: … <<< FAILURE!` or `<<< ERROR!`) re-enters the trail with the same keep/drop decision, so every failure message survives (and a capped class drops *all* its blocks). Any other non-blank line disarms the re-entry. +- **`<<< ERROR!` markers** — per-test sublines use `<<< ERROR!` for thrown (non-assertion) exceptions; the close-line regex also tolerates an `ERROR!` marker defensively (Surefire 3.5.5 emits `FAILURE!` even for errors-only classes — failure detection keys off the `Failures`/`Errors` counts, not the marker). +- **Help-boilerplate stripping (all modes)** — the post-failure block Maven emits after `[ERROR] Failed to execute goal` (`See …`, `-> [Help 1]`, `Re-run Maven`, `To see the full stack trace`, `For more information`, help URLs, bare `[ERROR]` dividers) is dropped in quiet *and* non-quiet filters alike (shared `BOILER_PREFIXES`). Deliberately kept as signal: `Failed to execute goal` itself and the multi-module resume hint (`[ERROR] After correcting the problems…` + `[ERROR] mvn -rf :module` — tells the user/agent how to resume the build). Real durations (`Time elapsed: … s`, `Total time: …`) ship untouched — the numbers are diagnostic signal. +- **Wrapper detection** — `./mvnw` (POSIX) and `mvnw.cmd` (Windows) detected via string-literal `Command::new` (semgrep-safe); falls back to `resolved_command("mvn")`. +- **Reactor Summary preservation** — for multi-module builds, the trailing `Reactor Summary for ` block with per-module SUCCESS/FAILURE rows is kept (toggled by a `[INFO] Reactor Summary for ` header and cleared on `BUILD SUCCESS` / `BUILD FAILURE`). +- **Failure cap** — both the count of emitted failing test classes and the size of the `[ERROR] Failures:` summary block are bounded by `MAX_MVN_FAILING_CLASSES = CAP_WARNINGS` (the shared test-failure cap class from `src/core/truncate.rs`, same binding as pytest/rspec/rake/runner). Excess emissions are replaced by a single `… +N more failing test classes` / `… +N more failures` tail (canonical `join_with_overflow` shape) to keep large failure sets compact; the raw output stays recoverable via the tee `[full output: …]` hint. Per the core cap policy, a cap of `0` means summary-only: no blocks emitted, the tail still counts every dropped class. + +Token-savings tests run inline as part of `cargo test --all` and verify ≥90% savings for `mvn test` and ≥85% for `mvn install` on full synthetic fixtures (gzipped, ~1100 lines each). The `flate2` dependency (already in `Cargo.toml`) decompresses the ~3 KB gzipped fixtures in milliseconds. + +### Integrity-check whitelist + +`Commands::Mvn` is intentionally omitted from `is_operational_command` in `src/main.rs`, matching the gradle precedent (`Commands::Gradlew` also omitted). The whitelist guards SHA-256 hook-integrity verification; filter modules invoked through an already-verified hook do not need a second check on their own dispatch path. Per the comment above the function, the whitelist is opt-in by design and a forgotten command fails open rather than creating false confidence about what's protected. + +## Gradle (`gradlew_cmd.rs`) + +See module docs and the gradle PR (`feat/gradlew-android-support`) for rationale. Streaming filter chosen because Gradle output is task-line-based, not block-based — unlike Maven Surefire. diff --git a/src/cmds/jvm/mvn_cmd.rs b/src/cmds/jvm/mvn_cmd.rs new file mode 100644 index 000000000..3ec2c5e08 --- /dev/null +++ b/src/cmds/jvm/mvn_cmd.rs @@ -0,0 +1,2112 @@ +//! Apache Maven filter — Surefire/Failsafe block collapse, compile error/warning +//! dedup, package/install pipeline with mode-toggle. +//! +//! Replaces the previous `src/filters/mvn-build.toml` filter with a Rust module +//! capable of state-machine parsing (block collapse, continuation tracking, +//! mode toggle) that TOML DSL cannot express. + +use crate::core::runner::{self, RunOptions}; +use crate::core::truncate::CAP_WARNINGS; +use crate::core::utils::{resolved_command, strip_ansi}; +use anyhow::Result; +use lazy_static::lazy_static; +use regex::Regex; +use std::collections::HashSet; +use std::ffi::OsString; +use std::path::Path; +use std::process::Command; + +/// Cap on emitted failing test-class blocks and `[ERROR] Failures:` summary +/// entries — test-failure cap class, same binding as pytest/rspec/rake/runner. +const MAX_MVN_FAILING_CLASSES: usize = CAP_WARNINGS; + +// ── Shared regex patterns ──────────────────────────────────────────────────── + +lazy_static! { + /// `[INFO] Running com.example.app.FooTest` + static ref RUNNING: Regex = Regex::new(r"^\[INFO\] Running ").unwrap(); + + /// Surefire/Failsafe per-class close line. Captures `Failures` and `Errors`. + /// Tolerates the optional `<<< FAILURE!` / `<<< ERROR!` marker (3.5.5 emits + /// `<<< FAILURE!` even for errors-only classes — see + /// `mvn_test_multifail_slice_raw.txt`; `ERROR!` accepted defensively for + /// other Surefire versions; failure detection is via the captured counts, + /// not the marker). Separator is `-` (Surefire 2.x) or `--` (Surefire 3.x). + /// Prefix INFO/ERROR/WARNING (3.x emits WARNING for classes with only + /// skipped tests). + static ref CLOSE: Regex = Regex::new( + r"^\[(?:INFO|ERROR|WARNING)\] Tests run: \d+, Failures: (\d+), Errors: (\d+), Skipped: \d+, Time elapsed: [^ ]+ s(?:\s+<<<\s*(?:FAILURE|ERROR)!)?\s+--?\s+in (.+)$" + ).unwrap(); + + /// Final BUILD footer. + static ref BUILD_FOOT: Regex = Regex::new(r"^\[(?:INFO|ERROR)\] BUILD (?:SUCCESS|FAILURE)$").unwrap(); + + /// `[INFO] Results:` separator before the aggregate. + static ref RESULTS: Regex = Regex::new(r"^\[INFO\] Results:\s*$").unwrap(); + + /// Aggregate counts line (no `Time elapsed`, no ` - in `). + static ref AGG: Regex = Regex::new( + r"^\[(?:INFO|ERROR)\] Tests run: \d+, Failures: \d+, Errors: \d+, Skipped: \d+\s*$" + ).unwrap(); + + /// Plugin banner line: `[INFO] --- plugin:goal (id) @ module ---`. + static ref PLUGIN_BANNER: Regex = Regex::new(r"^\[INFO\] --- .* @ .* ---$").unwrap(); + + /// Module banner with project name in brackets. + static ref MODULE_BANNER: Regex = Regex::new(r"^\[INFO\] -+< .+ >-+$").unwrap(); + + /// Reactor summary header that opens the per-module pass/fail block at + /// the end of a multi-module build. + static ref REACTOR_SUMMARY: Regex = Regex::new(r"^\[INFO\] Reactor Summary for ").unwrap(); + + /// Compile-error coordinate substring to strip when deduping warnings/errors. + static ref FILE_COORD: Regex = Regex::new(r"/[^:]+\.java:\[\d+,\d+\]").unwrap(); +} + +// ── Quiet-mode detection ──────────────────────────────────────────────────── + +/// `mvn -q` / `mvn --quiet` suppresses all `[INFO]` lines: no `BUILD SUCCESS` +/// footer, no `[INFO] Running` markers, no module banners. A passing run emits +/// **zero bytes**; a failing run emits only `[ERROR]`-prefixed lines plus the +/// stack trace. The standard filters key off `[INFO]` markers and the footer +/// guard, so they can't fire here — `filter_quiet` handles this case instead. +fn is_quiet(args: &[String]) -> bool { + args.iter().any(|a| a == "-q" || a == "--quiet") +} + +// ── Phase detection ───────────────────────────────────────────────────────── + +#[derive(Debug, PartialEq, Clone, Copy)] +pub enum MvnPhase { + Test, // test, integration-test (Failsafe = Surefire shape) + Compile, // compile, test-compile + Package, // package, install, verify, deploy + Passthrough, // clean, site, plugin goals, version/help, empty +} + +/// Scan args left-to-right, skip flags + `-D…` system props, pick the LAST +/// remaining token. If empty, plugin-form (`:`), or `clean`/`site` → Passthrough. +pub fn detect_phase(args: &[String]) -> MvnPhase { + let last = args + .iter() + .filter(|a| !a.starts_with('-')) + .map(|s| s.as_str()) + .next_back() + .unwrap_or(""); + + if last.is_empty() || last.contains(':') { + return MvnPhase::Passthrough; + } + match last { + "clean" | "site" | "site-deploy" => MvnPhase::Passthrough, + "test" | "integration-test" => MvnPhase::Test, + "compile" | "test-compile" => MvnPhase::Compile, + "package" | "install" | "verify" | "deploy" => MvnPhase::Package, + _ => MvnPhase::Passthrough, + } +} + +// ── Stack-frame deny-list ──────────────────────────────────────────────────── + +const FRAMEWORK_FRAME_PREFIXES: &[&str] = &[ + "at org.junit.", + "at junit.", + "at org.apache.maven.surefire.", + "at sun.reflect.", + "at jdk.internal.reflect.", + "at jdk.proxy", + "at java.base/", + "at java.lang.reflect.", + "at java.util.", +]; + +fn is_framework_frame(trimmed: &str) -> bool { + FRAMEWORK_FRAME_PREFIXES + .iter() + .any(|p| trimmed.starts_with(p)) +} + +/// Boilerplate `[ERROR]` lines Maven emits after `Failed to execute goal` — +/// pure noise pointing at log files and help URLs, no signal for the user/LLM. +/// Deliberately excludes `[ERROR] After correcting the problems` and +/// `[ERROR] mvn -rf :…` (the resume hint is actionable signal for a +/// multi-module build) and `[ERROR] Failed to execute goal` (signal). +const BOILER_PREFIXES: &[&str] = &[ + "[ERROR] See ", + "[ERROR] -> [Help", + "[ERROR] To see the full stack trace", + "[ERROR] Re-run Maven", + "[ERROR] For more information", + "[ERROR] [Help", +]; + +/// Post-failure help boilerplate, plus the bare `[ERROR]` divider lines Maven +/// emits between boilerplate blocks (same drop rules as `filter_quiet`). +fn is_boilerplate(line: &str) -> bool { + BOILER_PREFIXES.iter().any(|p| line.starts_with(p)) || line.trim_end() == "[ERROR]" +} + +/// `[ERROR] FQN.method -- Time elapsed: 0.030 s <<< FAILURE!` (or `<<< ERROR!`). +/// Distinguished from CLOSE by call position: only consulted when +/// `in_block == false` (CLOSE only occurs while a block is open). A +/// CLOSE-shaped line outside a block would match too — acceptable: the +/// disarm-on-take guard limits the effect to one stray line. +/// Note: the `[ERROR] Class.test:25 …` failures-summary entries (3-space +/// indent, no `<<<` marker) do NOT match. +fn is_per_test_subline(line: &str) -> bool { + line.starts_with("[ERROR] ") + && (line.contains("<<< FAILURE!") || line.contains("<<< ERROR!")) +} + +// ── English-footer guard ──────────────────────────────────────────────────── + +fn has_english_footer(stripped: &str) -> bool { + stripped.lines().any(|l| { + let t = l.trim(); + t.ends_with(" BUILD SUCCESS") || t.ends_with(" BUILD FAILURE") + }) +} + +// ── Outside-block keep list (shared by surefire + package) ────────────────── + +/// Multi-module reactor summary keeper. Reads `in_reactor_summary` and toggles +/// it on `[INFO] Reactor Summary for …` (enter) and `BUILD SUCCESS`/`BUILD +/// FAILURE` (exit). Returns `true` for every line while the flag is set so the +/// per-module status rows (`[INFO] foo ...... SUCCESS [ 1.234 s]`, plain +/// `[INFO]` separators inside the summary, etc.) survive. Returns `false` +/// otherwise — the caller's outside-block keep-list still applies. +/// +/// Designed to be called **before** `keep_outside_block` so the `BUILD_FOOT` +/// clears-flag side effect always runs regardless of `||` short-circuit. +fn reactor_summary_keep(line: &str, in_reactor_summary: &mut bool) -> bool { + if REACTOR_SUMMARY.is_match(line) { + *in_reactor_summary = true; + return true; + } + if BUILD_FOOT.is_match(line) { + *in_reactor_summary = false; + return false; + } + *in_reactor_summary +} + +fn keep_outside_block(line: &str) -> bool { + // Help boilerplate must be rejected before the `[ERROR]` catch-all below + // (non-quiet parity with `filter_quiet`'s boilerplate stripping). + if is_boilerplate(line) { + return false; + } + RESULTS.is_match(line) + || AGG.is_match(line) + || BUILD_FOOT.is_match(line) + || MODULE_BANNER.is_match(line) + || line.starts_with("[INFO] Total time:") + || line.starts_with("[INFO] Finished at:") + || line.starts_with("[INFO] Building ") + || line.starts_with("[INFO] Scanning ") + || line.starts_with("[INFO] Installing ") + || line.starts_with("[ERROR] Failures:") + || line.starts_with("[ERROR] Errors:") + || (line.starts_with("[ERROR]") && !line.starts_with("[ERROR] Tests run:")) + || line.starts_with("[INFO] Building war:") + || line.starts_with("[INFO] Building jar:") + || line.starts_with("[INFO] Building ear:") +} + +// ── Surefire block filter ─────────────────────────────────────────────────── + +/// Shared state machine driving the inner Surefire block + failure-trail +/// behaviour for `filter_surefire` and `filter_package`. Each filter wraps it +/// with its own outside-block keep logic (`[WARNING]` dedup, module-banner +/// keep, `keep_continuation` for compile-error continuations, etc.) which is +/// applied on the [`SurefireStep::Passthrough`] arm. +/// +/// Inner machine responsibilities: +/// - `[INFO] --- … @ … ---` plugin banner skip +/// - `[INFO] Running ` opens a buffered block (flushes any prior open +/// block as keep — happens on truncated output) +/// - in-block buffering until the next CLOSE line +/// - CLOSE with `Failures > 0` or `Errors > 0` → yields +/// [`SurefireStep::FailingClose`] so the outer loop can decide whether to +/// emit (this seam enforces [`MAX_MVN_FAILING_CLASSES`]) +/// - failure-trail handling for the exception/user-frame trail Surefire 3.x +/// emits **after** the close line, terminated by a blank line. Framework +/// frames (junit, jdk.proxy, java.base, etc.) are stripped from both the +/// buffered block and the trail; user-code frames are preserved. +/// - multi-failure classes: Surefire 3.x emits one blank-separated detail +/// block per failing test under a single CLOSE line. When a trail ends at +/// a blank line, `trail_rearm` remembers the keep/drop decision so the +/// next per-test subline re-enters the trail with the same decision. +/// End-of-input with `trail_rearm` still `Some` is harmless (nothing +/// pending in `out`); `finish()` / `flush_open_block_as_keep` need no +/// special handling. +struct SurefireBlock<'a> { + block_lines: Vec<&'a str>, + block_running: Option<&'a str>, + in_block: bool, + failure_trail: bool, + /// When set together with `failure_trail`, consumes the trail (per-test + /// `<<< FAILURE!` subline, exception, user frames) without writing it to + /// `out`. Used when the caller capped a failing block via `drop_failing`. + drop_trail: bool, + /// Set when a trail ends at a blank line; holds the `drop_trail` value so + /// the next per-test subline of the same class re-enters the trail with + /// the same keep/drop decision (a capped class must drop **all** its + /// per-test blocks, not just the first). Cleared by any non-blank + /// non-subline line, by `RUNNING`, and by `commit_failing`/`drop_failing`. + trail_rearm: Option, +} + +enum SurefireStep<'a> { + /// Inner machine consumed the line; outer loop should `continue;`. + Consumed, + /// A CLOSE line with `Failures > 0` or `Errors > 0` was reached. Outer + /// loop decides whether to commit (via [`SurefireBlock::commit_failing`]). + FailingClose { + running: Option<&'a str>, + lines: Vec<&'a str>, + close: &'a str, + }, + /// Inner machine did not handle the line; outer loop applies its own + /// outside-block keep logic. + Passthrough, +} + +impl<'a> SurefireBlock<'a> { + fn new() -> Self { + Self { + block_lines: Vec::new(), + block_running: None, + in_block: false, + failure_trail: false, + drop_trail: false, + trail_rearm: None, + } + } + + fn step(&mut self, line: &'a str, out: &mut String) -> SurefireStep<'a> { + if PLUGIN_BANNER.is_match(line) { + return SurefireStep::Consumed; + } + + if RUNNING.is_match(line) { + if self.in_block { + self.flush_open_block_as_keep(out); + } + self.block_lines.clear(); + self.block_running = Some(line); + self.in_block = true; + self.failure_trail = false; + // Load-bearing: a capped multi-failure class followed by a kept + // class must not re-arm into the new class's trail decision. + self.trail_rearm = None; + return SurefireStep::Consumed; + } + + if self.in_block { + if let Some(caps) = CLOSE.captures(line) { + let fail = caps.get(1).map(|m| m.as_str() != "0").unwrap_or(false); + let err = caps.get(2).map(|m| m.as_str() != "0").unwrap_or(false); + if fail || err { + let lines = std::mem::take(&mut self.block_lines); + let running = self.block_running.take(); + self.in_block = false; + return SurefireStep::FailingClose { + running, + lines, + close: line, + }; + } + self.block_lines.clear(); + self.block_running = None; + self.in_block = false; + return SurefireStep::Consumed; + } + self.block_lines.push(line); + return SurefireStep::Consumed; + } + + if self.failure_trail { + if line.is_empty() { + if !self.drop_trail { + out.push('\n'); + } + // Arm re-entry: a following per-test subline belongs to the + // same class and must inherit this trail's keep/drop decision. + self.trail_rearm = Some(self.drop_trail); + self.failure_trail = false; + self.drop_trail = false; + return SurefireStep::Consumed; + } + let t = line.trim_start(); + if t.starts_with("at ") && is_framework_frame(t) { + return SurefireStep::Consumed; + } + if self.drop_trail { + return SurefireStep::Consumed; + } + out.push_str(line); + out.push('\n'); + return SurefireStep::Consumed; + } + + if let Some(dropped) = self.trail_rearm { + if line.is_empty() { + // Tolerate extra blanks between per-test blocks: stay armed, + // let the blank fall through (outer keep-lists drop it). + return SurefireStep::Passthrough; + } + self.trail_rearm = None; // disarm unconditionally on non-blank (load-bearing) + if is_per_test_subline(line) { + self.failure_trail = true; + self.drop_trail = dropped; + if !dropped { + out.push_str(line); + out.push('\n'); + } + return SurefireStep::Consumed; + } + // Non-subline: trail is over; already disarmed — fall through. + } + + SurefireStep::Passthrough + } + + /// Mark a `FailingClose` as dropped (cap exceeded). The block itself is + /// already extracted by `step()`; this sets `failure_trail` so the + /// post-close trail (per-test subline, exception, user frames) is + /// consumed and silently dropped until the next blank line. + fn drop_failing(&mut self) { + self.failure_trail = true; + self.drop_trail = true; + // Belt-and-suspenders: a CLOSE can only follow a RUNNING (which + // already cleared `trail_rearm`), but keep the invariant local too. + self.trail_rearm = None; + } + + /// Commit a `FailingClose` to `out`: writes `running`, then `lines` (with + /// framework frames stripped), then `close`. Enables `failure_trail` so + /// the post-close exception/user-frame trail is preserved. + fn commit_failing( + &mut self, + out: &mut String, + running: Option<&str>, + lines: &[&str], + close: &str, + ) { + if let Some(r) = running { + out.push_str(r); + out.push('\n'); + } + for l in lines { + let t = l.trim_start(); + if t.starts_with("at ") && is_framework_frame(t) { + continue; + } + out.push_str(l); + out.push('\n'); + } + out.push_str(close); + out.push('\n'); + self.failure_trail = true; + // Belt-and-suspenders: see `drop_failing`. + self.trail_rearm = None; + } + + /// End-of-stream flush: if a block opened and never closed (truncated + /// output), surface what we have rather than dropping it silently. + fn finish(&mut self, out: &mut String) { + if self.in_block { + self.flush_open_block_as_keep(out); + } + } + + fn flush_open_block_as_keep(&mut self, out: &mut String) { + if let Some(r) = self.block_running.take() { + out.push_str(r); + out.push('\n'); + } + for l in self.block_lines.drain(..) { + out.push_str(l); + out.push('\n'); + } + self.in_block = false; + } +} + +/// `[ERROR] Failures:` summary block cap. Maven emits a summary at the end of +/// a failing test run: +/// +/// ```text +/// [ERROR] Failures: +/// [ERROR] ClassA.testFoo:25 expected: but was: +/// [ERROR] ClassB.testBar:42 expected: but was: +/// [INFO] +/// [ERROR] Tests run: 100, Failures: 50, Errors: 0, Skipped: 0 +/// ``` +/// +/// The aggregate `[ERROR] Tests run:` line is matched by `AGG` and kept; the +/// `[ERROR] ` entries are kept by the catch-all `[ERROR]` keeper. On builds +/// with hundreds of failures this can be quite large. Cap entries at +/// [`MAX_MVN_FAILING_CLASSES`] and emit `\n… +N more failures\n` immediately +/// before the `Tests run:` aggregate when entries were dropped. +struct FailuresSummaryCap { + cap: usize, + in_summary: bool, + emitted: usize, + dropped: usize, +} + +impl FailuresSummaryCap { + fn new(cap: usize) -> Self { + Self { + cap, + in_summary: false, + emitted: 0, + dropped: 0, + } + } + + /// If `line` is an `[ERROR] ` entry inside the failures summary, write + /// it (or count it as dropped) and return `true` so the caller skips its + /// own keep-list. Returns `false` otherwise. + fn handle_entry(&mut self, line: &str, out: &mut String) -> bool { + if !self.in_summary || !line.starts_with("[ERROR] ") { + return false; + } + // Per core cap policy, `0` means summary-only: no entries, tail still counts. + if self.emitted < self.cap { + out.push_str(line); + out.push('\n'); + self.emitted += 1; + } else { + self.dropped += 1; + } + true + } + + /// Detect the `[ERROR] Failures:` header so subsequent `[ERROR] ` lines + /// get capped. Caller is responsible for writing the header to `out`. + fn handle_header(&mut self, line: &str) { + if line.starts_with("[ERROR] Failures:") { + self.in_summary = true; + self.emitted = 0; + self.dropped = 0; + } + } + + /// Pre-emit the `… +N more failures` tail when the aggregate + /// `[ERROR] Tests run:` line is about to be written, then close the + /// summary. Caller writes the AGG line itself afterwards. + fn handle_aggregate(&mut self, line: &str, out: &mut String) { + if !self.in_summary || !AGG.is_match(line) { + return; + } + if self.dropped > 0 { + out.push_str(&format!("\n… +{} more failures\n", self.dropped)); + } + self.in_summary = false; + self.emitted = 0; + self.dropped = 0; + } + + /// End-of-stream tail emission for cases where the AGG line never arrives + /// (truncated output). Emits the tail with no trailing newline guard so + /// the resulting filtered output is still well-formed. + fn finish(&mut self, out: &mut String) { + if self.in_summary && self.dropped > 0 { + out.push_str(&format!("\n… +{} more failures\n", self.dropped)); + } + } +} + +/// Buffered single-pass filter for `mvn test` / `mvn integration-test`. +/// +/// Drives [`SurefireBlock`] for the inner block/trail machine; applies the +/// outside-block keep-list with `keep_continuation` for indented compile-error +/// continuations (`symbol:` / `location:` after a `[ERROR] cannot find symbol` +/// line). +/// +/// English-footer guard: if no `BUILD SUCCESS`/`BUILD FAILURE` line is present, +/// return the ANSI-stripped raw input (non-English locale or truncated output). +pub fn filter_surefire(raw: &str) -> String { + filter_surefire_with_cap(raw, MAX_MVN_FAILING_CLASSES) +} + +fn filter_surefire_with_cap(raw: &str, cap: usize) -> String { + let stripped = strip_ansi(raw); + if !has_english_footer(&stripped) { + return stripped; + } + + let mut out = String::new(); + let mut block = SurefireBlock::new(); + let mut keep_continuation = false; + let mut in_reactor_summary = false; + let mut emitted_failing: usize = 0; + let mut dropped_failing: usize = 0; + let mut summary = FailuresSummaryCap::new(cap); + + for line in stripped.lines() { + match block.step(line, &mut out) { + SurefireStep::Consumed => continue, + SurefireStep::FailingClose { + running, + lines, + close, + } => { + if emitted_failing < cap { + block.commit_failing(&mut out, running, &lines, close); + emitted_failing += 1; + } else { + block.drop_failing(); + dropped_failing += 1; + } + keep_continuation = false; + continue; + } + SurefireStep::Passthrough => {} + } + + if keep_continuation && (line.starts_with(' ') || line.starts_with('\t')) { + out.push_str(line); + out.push('\n'); + continue; + } + + // Failures-summary cap: gate `[ERROR] ` entries, emit `+N more` tail + // before AGG. The helper consumes only summary entries — other lines + // (header, AGG) fall through to the keep-list below. + if summary.handle_entry(line, &mut out) { + continue; + } + + // Order matters: call reactor_summary_keep first so its BUILD_FOOT + // clears-flag side effect always runs regardless of `||` short-circuit. + let reactor_keep = reactor_summary_keep(line, &mut in_reactor_summary); + if reactor_keep || keep_outside_block(line) { + // Pre-emit the summary tail when we're about to write AGG. + summary.handle_aggregate(line, &mut out); + // Detect summary header so subsequent `[ERROR] ` entries get capped. + summary.handle_header(line); + out.push_str(line); + out.push('\n'); + keep_continuation = line.starts_with("[ERROR]") + && !line.starts_with("[ERROR] Tests run:") + && !line.starts_with("[ERROR] Failures:") + && !line.starts_with("[ERROR] Errors:"); + continue; + } + // Dropped line (e.g. help boilerplate): reset so a stale flag can't + // keep an indented line that follows a dropped `[ERROR]` line. + // Parity with filter_package's fall-through reset. + keep_continuation = false; + } + + block.finish(&mut out); + summary.finish(&mut out); + if dropped_failing > 0 { + out.push_str(&format!( + "\n… +{} more failing test classes\n", + dropped_failing + )); + } + out +} + +// ── Compile filter ────────────────────────────────────────────────────────── + +/// Buffered single-pass filter for `mvn compile` / `test-compile`. +/// +/// Keeps module banners, `[INFO] Building …`, `[INFO] BUILD …`, totals, finish +/// time, scanning line, install lines, and `[ERROR]` blocks with indented +/// continuation (` symbol:`, ` ^`, ` required:`). Deduplicates `[WARNING]` +/// lines by normalised message (strip file coordinates). +pub fn filter_compile(raw: &str) -> String { + let stripped = strip_ansi(raw); + if !has_english_footer(&stripped) { + return stripped; + } + + let mut out = String::new(); + let mut keep_continuation = false; + let mut seen_warnings: HashSet = HashSet::new(); + + for line in stripped.lines() { + if MODULE_BANNER.is_match(line) { + out.push_str(line); + out.push('\n'); + keep_continuation = false; + continue; + } + if BUILD_FOOT.is_match(line) + || line.starts_with("[INFO] Building ") + || line.starts_with("[INFO] Total time:") + || line.starts_with("[INFO] Finished at:") + || line.starts_with("[INFO] Scanning ") + { + out.push_str(line); + out.push('\n'); + keep_continuation = false; + continue; + } + // Help boilerplate: drop before the `[ERROR]` catch-all (parity with + // keep_outside_block / filter_quiet). + if is_boilerplate(line) { + keep_continuation = false; + continue; + } + if line.starts_with("[ERROR]") { + out.push_str(line); + out.push('\n'); + keep_continuation = true; + continue; + } + if keep_continuation && (line.starts_with(' ') || line.starts_with('\t')) { + out.push_str(line); + out.push('\n'); + continue; + } + if line.starts_with("[WARNING]") { + let payload = line.strip_prefix("[WARNING] ").unwrap_or(line); + let norm = FILE_COORD.replace_all(payload, "").to_string(); + if seen_warnings.insert(norm) { + out.push_str(line); + out.push('\n'); + } + keep_continuation = false; + continue; + } + // Drop everything else + keep_continuation = false; + } + + out +} + +// ── Package filter ────────────────────────────────────────────────────────── + +/// Buffered single-pass filter for `mvn package`/`install`/`verify`/`deploy`. +/// +/// Mode toggle: starts in `Compile` mode, switches to `Surefire` when a +/// `[INFO] Running …` line is seen, switches back on `Tests run:` close. +/// Outside any Surefire block, applies the unified keep-list (compile keepers +/// + install/artifact lines). +pub fn filter_package(raw: &str) -> String { + filter_package_with_cap(raw, MAX_MVN_FAILING_CLASSES) +} + +fn filter_package_with_cap(raw: &str, cap: usize) -> String { + let stripped = strip_ansi(raw); + if !has_english_footer(&stripped) { + return stripped; + } + + let mut out = String::new(); + let mut block = SurefireBlock::new(); + let mut keep_continuation = false; + let mut in_reactor_summary = false; + let mut seen_warnings: HashSet = HashSet::new(); + let mut emitted_failing: usize = 0; + let mut dropped_failing: usize = 0; + let mut summary = FailuresSummaryCap::new(cap); + + for line in stripped.lines() { + match block.step(line, &mut out) { + SurefireStep::Consumed => continue, + SurefireStep::FailingClose { + running, + lines, + close, + } => { + if emitted_failing < cap { + block.commit_failing(&mut out, running, &lines, close); + emitted_failing += 1; + } else { + block.drop_failing(); + dropped_failing += 1; + } + keep_continuation = false; + continue; + } + SurefireStep::Passthrough => {} + } + + // Failures-summary cap (see filter_surefire_with_cap for details). + if summary.handle_entry(line, &mut out) { + continue; + } + + // Order matters: call reactor_summary_keep first so its BUILD_FOOT + // clears-flag side effect always runs regardless of `||` short-circuit. + let reactor_keep = reactor_summary_keep(line, &mut in_reactor_summary); + // Outside any Surefire block: compile-keep AND surefire-outside-keep merge. + if reactor_keep || MODULE_BANNER.is_match(line) || keep_outside_block(line) { + summary.handle_aggregate(line, &mut out); + summary.handle_header(line); + out.push_str(line); + out.push('\n'); + keep_continuation = line.starts_with("[ERROR]") + && !line.starts_with("[ERROR] Tests run:") + && !line.starts_with("[ERROR] Failures:") + && !line.starts_with("[ERROR] Errors:"); + continue; + } + if keep_continuation && (line.starts_with(' ') || line.starts_with('\t')) { + out.push_str(line); + out.push('\n'); + continue; + } + if line.starts_with("[WARNING]") { + let payload = line.strip_prefix("[WARNING] ").unwrap_or(line); + let norm = FILE_COORD.replace_all(payload, "").to_string(); + if seen_warnings.insert(norm) { + out.push_str(line); + out.push('\n'); + } + keep_continuation = false; + continue; + } + keep_continuation = false; + } + + block.finish(&mut out); + summary.finish(&mut out); + if dropped_failing > 0 { + out.push_str(&format!( + "\n… +{} more failing test classes\n", + dropped_failing + )); + } + out +} + +// ── Quiet-mode filter ─────────────────────────────────────────────────────── + +/// Filter for `mvn -q` invocations. +/// +/// Under `-q`, Maven 3.x suppresses all `[INFO]` lines, so the standard +/// `filter_surefire` / `filter_compile` / `filter_package` pipelines (which +/// key off the English `BUILD SUCCESS` footer and `[INFO] Running` markers) +/// can't fire. This filter handles the residual `-q` output shape: +/// +/// - Green run: input is empty → output is empty (0 → 0, no overhead). +/// - Failure run: keeps the Surefire close-line (`[ERROR] Tests run: … +/// <<< FAILURE! -- in FQN`), the per-test failure subline, exception class, +/// user-code stack frames, the failure summary block (`[ERROR] Failures:`, +/// indented entries, aggregate `Tests run: N, Failures: F, …`), and the +/// `[ERROR] Failed to execute goal` terminator. Drops framework stack +/// frames and the post-failure boilerplate block (`See …`, `[Help 1]`, +/// `Re-run Maven`, `To see the full stack trace`, etc.). +pub fn filter_quiet(raw: &str) -> String { + let stripped = strip_ansi(raw); + if stripped.trim().is_empty() { + return String::new(); + } + + let mut out = String::new(); + let mut failure_trail = false; + + for line in stripped.lines() { + // Surefire close-line for a failed class — keep + enter failure trail. + if CLOSE.is_match(line) { + out.push_str(line); + out.push('\n'); + failure_trail = + line.contains("<<< FAILURE!") || line.contains("<<< ERROR!"); + continue; + } + + // Per-test failure subline: `[ERROR] FQN.method -- Time elapsed: … <<< FAILURE!` + // (or `<<< ERROR!` for thrown exceptions). + if is_per_test_subline(line) { + out.push_str(line); + out.push('\n'); + failure_trail = true; + continue; + } + + // Failure-trail body: exception class, user-code frames; drop framework frames. + if failure_trail { + if line.trim().is_empty() { + out.push('\n'); + failure_trail = false; + continue; + } + let t = line.trim_start(); + if t.starts_with("at ") && is_framework_frame(t) { + continue; + } + out.push_str(line); + out.push('\n'); + continue; + } + + // Failure summary keepers. + if line.starts_with("[ERROR] Tests run:") + || line.starts_with("[ERROR] Failures:") + || line.starts_with("[ERROR] Errors:") + || line.starts_with("[ERROR] ") + || line.starts_with("[ERROR] Failed to execute goal") + { + out.push_str(line); + out.push('\n'); + continue; + } + + // Drop post-failure help boilerplate and bare `[ERROR]` dividers + // (shared with the non-quiet filters — see BOILER_PREFIXES). + if is_boilerplate(line) { + continue; + } + + // Safety net: keep anything else (unexpected output under `-q` is rare; + // do not silently drop signal we haven't classified). + out.push_str(line); + out.push('\n'); + } + + out +} + +// ── Wrapper detection ─────────────────────────────────────────────────────── + +fn mvn_binary() -> &'static str { + if cfg!(windows) { + if Path::new(".\\mvnw.cmd").exists() { + ".\\mvnw.cmd" + } else { + "mvn" + } + } else if Path::new("./mvnw").exists() { + "./mvnw" + } else { + "mvn" + } +} + +fn new_mvn_command(args: &[String]) -> Command { + let mut cmd = if cfg!(windows) { + if Path::new(".\\mvnw.cmd").exists() { + Command::new(".\\mvnw.cmd") + } else { + resolved_command("mvn") + } + } else if Path::new("./mvnw").exists() { + Command::new("./mvnw") + } else { + resolved_command("mvn") + }; + cmd.args(args); + cmd +} + +// ── Entry point ───────────────────────────────────────────────────────────── + +pub fn run(args: &[String], verbose: u8) -> Result { + // Verbose flags bypass filtering — user wants full output. + if args + .iter() + .any(|a| matches!(a.as_str(), "-X" | "--debug" | "-e" | "--errors")) + { + let osargs: Vec = args.iter().map(OsString::from).collect(); + return runner::run_passthrough(mvn_binary(), &osargs, verbose); + } + + let tool = mvn_binary(); + let args_display = args.join(" "); + + // Quiet mode: standard footer guard can't fire (no `BUILD SUCCESS` line + // under `-q`). Route to `filter_quiet` for any non-passthrough phase so + // failure output gets framework frames + help boilerplate stripped. + if is_quiet(args) { + let phase = detect_phase(args); + if matches!(phase, MvnPhase::Passthrough) { + let osargs: Vec = args.iter().map(OsString::from).collect(); + return runner::run_passthrough(tool, &osargs, verbose); + } + return runner::run_filtered( + new_mvn_command(args), + tool, + &args_display, + filter_quiet, + RunOptions::with_tee("mvn_quiet"), + ); + } + + let phase = detect_phase(args); + + match phase { + MvnPhase::Test => runner::run_filtered( + new_mvn_command(args), + tool, + &args_display, + filter_surefire, + RunOptions::with_tee("mvn_test"), + ), + MvnPhase::Compile => runner::run_filtered( + new_mvn_command(args), + tool, + &args_display, + filter_compile, + RunOptions::with_tee("mvn_compile"), + ), + MvnPhase::Package => runner::run_filtered( + new_mvn_command(args), + tool, + &args_display, + filter_package, + RunOptions::with_tee("mvn_package"), + ), + MvnPhase::Passthrough => { + let osargs: Vec = args.iter().map(OsString::from).collect(); + runner::run_passthrough(tool, &osargs, verbose) + } + } +} + +// ── Tests ─────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use flate2::read::GzDecoder; + use std::io::Read; + + fn count_tokens(s: &str) -> usize { + s.split_whitespace().count() + } + + fn gunzip(bytes: &[u8]) -> String { + let mut s = String::new(); + GzDecoder::new(bytes) + .read_to_string(&mut s) + .expect("gunzip"); + s + } + + fn s>(it: impl IntoIterator) -> Vec { + it.into_iter().map(Into::into).collect() + } + + // ── Phase detection ────────────────────────────────────────────────────── + + #[test] + fn phase_test() { + assert_eq!(detect_phase(&s(["test"])), MvnPhase::Test); + } + #[test] + fn phase_integration_test() { + assert_eq!(detect_phase(&s(["integration-test"])), MvnPhase::Test); + } + #[test] + fn phase_compile() { + assert_eq!(detect_phase(&s(["compile"])), MvnPhase::Compile); + } + #[test] + fn phase_test_compile() { + assert_eq!(detect_phase(&s(["test-compile"])), MvnPhase::Compile); + } + #[test] + fn phase_install() { + assert_eq!(detect_phase(&s(["install"])), MvnPhase::Package); + } + #[test] + fn phase_package() { + assert_eq!(detect_phase(&s(["package"])), MvnPhase::Package); + } + #[test] + fn phase_verify() { + assert_eq!(detect_phase(&s(["verify"])), MvnPhase::Package); + } + #[test] + fn phase_deploy() { + assert_eq!(detect_phase(&s(["deploy"])), MvnPhase::Package); + } + #[test] + fn phase_clean_install_is_pkg() { + assert_eq!(detect_phase(&s(["clean", "install"])), MvnPhase::Package); + } + #[test] + fn phase_flags_before_goal() { + assert_eq!( + detect_phase(&s(["-B", "-DskipTests", "test"])), + MvnPhase::Test + ); + } + #[test] + fn phase_clean_only_passthrough() { + assert_eq!(detect_phase(&s(["clean"])), MvnPhase::Passthrough); + } + #[test] + fn phase_site_passthrough() { + assert_eq!(detect_phase(&s(["site"])), MvnPhase::Passthrough); + } + #[test] + fn phase_plugin_goal_passthrough() { + assert_eq!( + detect_phase(&s(["dependency:tree"])), + MvnPhase::Passthrough + ); + } + #[test] + fn phase_empty_passthrough() { + let v: Vec = Vec::new(); + assert_eq!(detect_phase(&v), MvnPhase::Passthrough); + } + #[test] + fn phase_version_long() { + assert_eq!(detect_phase(&s(["--version"])), MvnPhase::Passthrough); + } + #[test] + fn phase_version_short() { + assert_eq!(detect_phase(&s(["-v"])), MvnPhase::Passthrough); + } + #[test] + fn phase_version_java_style() { + assert_eq!(detect_phase(&s(["-version"])), MvnPhase::Passthrough); + } + #[test] + fn phase_help() { + assert_eq!(detect_phase(&s(["--help"])), MvnPhase::Passthrough); + } + + // ── Surefire filter ────────────────────────────────────────────────────── + + #[test] + fn filter_surefire_pass_output_compact() { + let i = include_str!("../../../tests/fixtures/mvn_test_pass_slice_raw.txt"); + let o = filter_surefire(i); + // Passing fixture has 5 close lines; all should be dropped (no per-class line in output). + assert!(!o.contains("Running org.apache.commons.cli.help.UtilTest")); + assert!(!o.contains("Time elapsed: 1.023 s -- in")); + let savings = 100.0 - (count_tokens(&o) as f64 / count_tokens(i) as f64 * 100.0); + assert!( + savings >= 50.0, + "pass-fixture savings >=50%, got {:.1}%", + savings + ); + } + + #[test] + fn filter_surefire_fail_keeps_signal() { + let i = include_str!("../../../tests/fixtures/mvn_test_fail_slice_raw.txt"); + let o = filter_surefire(i); + assert!(o.contains("BUILD FAILURE")); + assert!(o.contains("Failures: 1")); + } + + #[test] + fn surefire_drops_passing_block() { + let i = include_str!("../../../tests/fixtures/mvn_test_pass_slice_raw.txt"); + let o = filter_surefire(i); + assert!( + !o.contains("at org.junit."), + "framework frames stripped; got:\n{}", + o + ); + assert!( + !o.contains("Running org.apache.commons.cli.ConverterTests"), + "passing-test Running line dropped; got:\n{}", + o + ); + assert!( + o.contains("BUILD SUCCESS"), + "footer preserved; got:\n{}", + o + ); + assert!( + o.contains("Tests run: 977, Failures: 0"), + "aggregate preserved; got:\n{}", + o + ); + } + + #[test] + fn surefire_preserves_failing_signal() { + let i = include_str!("../../../tests/fixtures/mvn_test_fail_slice_raw.txt"); + let o = filter_surefire(i); + assert!( + o.contains("Failures: 1"), + "failing aggregate preserved; got:\n{}", + o + ); + assert!( + o.contains("AssertionFailedError"), + "exception class preserved; got:\n{}", + o + ); + assert!( + o.contains("at org.apache.commons.cli.RtkInducedFailTest.rtkInducedFailure"), + "user-code frame preserved; got:\n{}", + o + ); + assert!( + !o.contains("at org.junit."), + "framework frames stripped in failing block; got:\n{}", + o + ); + } + + /// 2.x compat: CLOSE regex must still match the single-dash separator emitted + /// by Surefire 2.x. Locks the `--?` regex against accidental tightening. + #[test] + fn surefire_matches_legacy_2x_close_line() { + let i = "[INFO] -----< x >-----\n[INFO] Running x.Foo\n[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.123 s - in x.Foo\n[INFO] BUILD SUCCESS\n"; + let o = filter_surefire(i); + // CLOSE matched → passing block dropped silently. + assert!( + !o.contains("Running x.Foo"), + "2.x ` - in ` close-line matched; passing block dropped; got:\n{}", + o + ); + assert!( + o.contains("BUILD SUCCESS"), + "footer preserved; got:\n{}", + o + ); + } + + /// 3.x WARNING-prefixed close line (class with only skipped tests) must + /// match CLOSE so the block is dropped (no failures, no errors). + #[test] + fn surefire_matches_warning_skipped_close_line() { + let i = "[INFO] -----< x >-----\n[INFO] Running x.Skip\n[WARNING] Tests run: 5, Failures: 0, Errors: 0, Skipped: 5, Time elapsed: 0.010 s -- in x.Skip\n[INFO] BUILD SUCCESS\n"; + let o = filter_surefire(i); + assert!( + !o.contains("Running x.Skip"), + "[WARNING] close-line matched; block dropped; got:\n{}", + o + ); + } + + /// 3.x failure-trail: after a CLOSE with `<<< FAILURE!`, the exception + /// class and user-code frames Surefire emits *outside* the block must be + /// preserved until the next blank line. + #[test] + fn surefire_preserves_3x_failure_trail() { + let i = "[INFO] -----< x >-----\n\ + [INFO] Running x.Foo\n\ + [ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.033 s <<< FAILURE! -- in x.Foo\n\ + [ERROR] x.Foo.bar -- Time elapsed: 0.025 s <<< FAILURE!\n\ + org.opentest4j.AssertionFailedError: expected: but was: \n\ + \tat x.Foo.bar(Foo.java:25)\n\ + \tat org.junit.jupiter.api.Assertions.assertEquals(Assertions.java:1)\n\ + \n\ + [INFO] BUILD FAILURE\n"; + let o = filter_surefire(i); + assert!(o.contains("AssertionFailedError"), "exception preserved; got:\n{}", o); + assert!(o.contains("at x.Foo.bar"), "user frame preserved; got:\n{}", o); + assert!( + !o.contains("at org.junit."), + "framework frame stripped in trail; got:\n{}", + o + ); + } + + // ── Multi-failure class (trail re-arm) ────────────────────────────────── + + /// Surefire 3.x emits one blank-separated detail block per failing test + /// under a single CLOSE line. All per-test exception messages must survive + /// (not just the first), framework frames must stay stripped throughout. + /// Real fixture: `CalcTest` (1 failure + 1 error) + `BoomTest` (errors-only). + #[test] + fn surefire_keeps_all_failures_in_multi_failure_class() { + let i = include_str!("../../../tests/fixtures/mvn_test_multifail_slice_raw.txt"); + let o = filter_surefire(i); + assert!( + o.contains("AssertionFailedError: failOne: addition should equal five"), + "first failure message preserved; got:\n{}", + o + ); + assert!( + o.contains("IllegalStateException: failTwo: induced error"), + "second failure (ERROR! subline) message preserved; got:\n{}", + o + ); + assert!( + o.contains("at com.example.rtk.CalcTest.failOne(CalcTest.java:12)"), + "first user frame preserved; got:\n{}", + o + ); + assert!( + o.contains("at com.example.rtk.CalcTest.failTwo(CalcTest.java:17)"), + "second user frame preserved; got:\n{}", + o + ); + assert!( + !o.contains("at org.junit."), + "junit frames stripped; got:\n{}", + o + ); + assert!( + !o.contains("at java.base/"), + "jdk frames stripped; got:\n{}", + o + ); + } + + /// Same multi-failure fixture through `filter_package` (drift guard — + /// the install/verify route shares `SurefireBlock` and must not diverge). + #[test] + fn package_keeps_all_failures_in_multi_failure_class() { + let i = include_str!("../../../tests/fixtures/mvn_test_multifail_slice_raw.txt"); + let o = filter_package(i); + assert!( + o.contains("AssertionFailedError: failOne: addition should equal five"), + "first failure message preserved; got:\n{}", + o + ); + assert!( + o.contains("IllegalStateException: failTwo: induced error"), + "second failure message preserved; got:\n{}", + o + ); + assert!( + !o.contains("at org.junit."), + "junit frames stripped; got:\n{}", + o + ); + assert!( + !o.contains("at java.base/"), + "jdk frames stripped; got:\n{}", + o + ); + } + + /// A capped (dropped) multi-failure class must drop **all** its per-test + /// blocks — the re-arm inherits the drop decision — and the tail counts + /// classes, not failures. The existing `surefire_caps_failing_blocks_emits_tail` + /// only covers single-failure classes. + #[test] + fn surefire_drop_failing_drops_all_sublines_of_capped_class() { + let i = "[INFO] Scanning for projects...\n\ + [INFO] -----< x >-----\n\ + [INFO] Running x.FailA\n\ + [ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.011 s <<< FAILURE! -- in x.FailA\n\ + [ERROR] x.FailA.one -- Time elapsed: 0.010 s <<< FAILURE!\n\ + org.opentest4j.AssertionFailedError: boomA\n\ + \tat x.FailA.one(FailA.java:10)\n\ + \n\ + [INFO] Running x.MultiFail\n\ + [ERROR] Tests run: 2, Failures: 1, Errors: 1, Skipped: 0, Time elapsed: 0.051 s <<< FAILURE! -- in x.MultiFail\n\ + [ERROR] x.MultiFail.first -- Time elapsed: 0.020 s <<< FAILURE!\n\ + org.opentest4j.AssertionFailedError: boomFirst\n\ + \tat x.MultiFail.first(MultiFail.java:20)\n\ + \n\ + [ERROR] x.MultiFail.second -- Time elapsed: 0.030 s <<< ERROR!\n\ + java.lang.IllegalStateException: boomSecond\n\ + \tat x.MultiFail.second(MultiFail.java:30)\n\ + \n\ + [INFO] BUILD FAILURE\n"; + let o = filter_surefire_with_cap(i, 1); + + assert!(o.contains("boomA"), "first class kept; got:\n{}", o); + assert!( + !o.contains("Running x.MultiFail") && !o.contains("boomFirst"), + "capped class first block dropped; got:\n{}", + o + ); + assert!( + !o.contains("x.MultiFail.second") && !o.contains("boomSecond"), + "capped class second per-test block dropped (re-arm inherits drop); got:\n{}", + o + ); + assert!( + o.contains("… +1 more failing test classes"), + "tail counts one class, not one per failure; got:\n{}", + o + ); + } + + /// A non-subline line (`[INFO] Results:`) immediately after a trail blank + /// must disarm the re-arm and be kept normally by the outside-block list. + #[test] + fn surefire_rearm_disarms_at_results_boundary() { + let i = "[INFO] -----< x >-----\n\ + [INFO] Running x.MultiFail\n\ + [ERROR] Tests run: 2, Failures: 2, Errors: 0, Skipped: 0, Time elapsed: 0.051 s <<< FAILURE! -- in x.MultiFail\n\ + [ERROR] x.MultiFail.first -- Time elapsed: 0.020 s <<< FAILURE!\n\ + org.opentest4j.AssertionFailedError: boomFirst\n\ + \n\ + [ERROR] x.MultiFail.second -- Time elapsed: 0.030 s <<< FAILURE!\n\ + org.opentest4j.AssertionFailedError: boomSecond\n\ + \n\ + [INFO] Results:\n\ + [ERROR] Tests run: 2, Failures: 2, Errors: 0, Skipped: 0\n\ + [INFO] BUILD FAILURE\n"; + let o = filter_surefire(i); + assert!(o.contains("boomSecond"), "second block kept; got:\n{}", o); + assert!( + o.contains("[INFO] Results:"), + "Results boundary disarms re-arm and is kept; got:\n{}", + o + ); + assert!( + o.contains("[ERROR] Tests run: 2, Failures: 2"), + "aggregate kept; got:\n{}", + o + ); + } + + /// Double blank between per-test blocks: stay armed across the extra + /// blank, still re-enter the trail — and no spurious blank lines leak. + #[test] + fn surefire_tolerates_double_blank_between_failure_blocks() { + let i = "[INFO] -----< x >-----\n\ + [INFO] Running x.MultiFail\n\ + [ERROR] Tests run: 2, Failures: 2, Errors: 0, Skipped: 0, Time elapsed: 0.051 s <<< FAILURE! -- in x.MultiFail\n\ + [ERROR] x.MultiFail.first -- Time elapsed: 0.020 s <<< FAILURE!\n\ + org.opentest4j.AssertionFailedError: boomFirst\n\ + \n\ + \n\ + [ERROR] x.MultiFail.second -- Time elapsed: 0.030 s <<< FAILURE!\n\ + org.opentest4j.AssertionFailedError: boomSecond\n\ + \n\ + [INFO] BUILD FAILURE\n"; + let o = filter_surefire(i); + assert!(o.contains("boomFirst"), "first block kept; got:\n{}", o); + assert!( + o.contains("boomSecond"), + "second block re-enters trail across double blank; got:\n{}", + o + ); + assert!( + !o.contains("\n\n\n"), + "no spurious blank lines leak; got:\n{:?}", + o + ); + } + + /// Byte-exact pin of the single-failure path: the re-arm machinery must + /// not change output for single-failure fixtures (no extra blank lines, + /// no reordering). Literal captured from `filter_surefire` at the commit + /// preceding the trail re-arm change. + #[test] + fn surefire_single_failure_output_unchanged() { + let i = include_str!("../../../tests/fixtures/mvn_test_fail_slice_raw.txt"); + let o = filter_surefire(i); + let expected = "[INFO] Scanning for projects...\n\ + [INFO] ----------------------< commons-cli:commons-cli >-----------------------\n\ + [INFO] Building Apache Commons CLI 1.11.1-SNAPSHOT\n\ + [INFO] Running org.apache.commons.cli.RtkInducedFailTest\n\ + [ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.033 s <<< FAILURE! -- in org.apache.commons.cli.RtkInducedFailTest\n\ + [ERROR] org.apache.commons.cli.RtkInducedFailTest.rtkInducedFailure -- Time elapsed: 0.025 s <<< FAILURE!\n\ + org.opentest4j.AssertionFailedError: expected: but was: \n\ + \tat org.apache.commons.cli.RtkInducedFailTest.rtkInducedFailure(RtkInducedFailTest.java:25)\n\ + \n\ + [INFO] Results:\n\ + [ERROR] Failures:\n\ + [ERROR] RtkInducedFailTest.rtkInducedFailure:25 expected: but was: \n\ + [ERROR] Tests run: 978, Failures: 1, Errors: 0, Skipped: 61\n\ + [INFO] BUILD FAILURE\n\ + [INFO] Total time: 01:05 min\n\ + [INFO] Finished at: 2026-05-21T14:57:09Z\n\ + [ERROR] Failed to execute goal org.apache.maven.plugins:maven-surefire-plugin:3.5.5:test (default-test) on project commons-cli: There are test failures.\n"; + assert_eq!(o, expected, "single-failure output must be byte-identical"); + } + + /// Savings on the multifail slice. Threshold is low by design: the slice + /// is nearly all kept failure signal (two failing classes, three per-test + /// detail blocks), so the droppable share is small — measured 42.3% after + /// non-quiet boilerplate stripping (19.9% before it; precedent: + /// reactor-fail pins ≥30% with a "short fixture" note). + #[test] + fn savings_mvn_test_multifail_slice() { + let i = include_str!("../../../tests/fixtures/mvn_test_multifail_slice_raw.txt"); + let o = filter_surefire(i); + let savings = 100.0 - (count_tokens(&o) as f64 / count_tokens(i) as f64 * 100.0); + assert!( + savings >= 30.0, + "multifail slice ≥30% savings (dense failure-signal fixture), got {:.1}%", + savings + ); + } + + /// Non-quiet runs must strip the post-failure help boilerplate + /// (`-> [Help 1]`, `Re-run Maven`, `See …`, bare `[ERROR]` dividers) the + /// same way `filter_quiet` does, while keeping the `Failed to execute + /// goal` terminator (signal). + #[test] + fn surefire_drops_help_boilerplate_in_nonquiet_mode() { + let i = include_str!("../../../tests/fixtures/mvn_test_multifail_slice_raw.txt"); + let o = filter_surefire(i); + assert!( + o.contains("[ERROR] Failed to execute goal"), + "goal terminator kept; got:\n{}", + o + ); + assert!(!o.contains("[Help 1]"), "help link stripped; got:\n{}", o); + assert!( + !o.contains("Re-run Maven"), + "re-run hint stripped; got:\n{}", + o + ); + assert!( + !o.contains("To see the full stack trace"), + "stack-trace hint stripped; got:\n{}", + o + ); + assert!( + !o.contains("See dump files"), + "dump-file pointer stripped; got:\n{}", + o + ); + assert!( + !o.lines().any(|l| l.trim_end() == "[ERROR]"), + "bare [ERROR] dividers stripped; got:\n{}", + o + ); + } + + /// CLOSE regex accepts a `<<< ERROR!` marker (defensive — Surefire 3.5.5 + /// emits `<<< FAILURE!` even for errors-only classes, per the multifail + /// fixture capture; other versions may emit `ERROR!`). + #[test] + fn close_line_matches_error_marker() { + let line = "[ERROR] Tests run: 1, Failures: 0, Errors: 1, Skipped: 0, Time elapsed: 0.006 s <<< ERROR! -- in com.example.rtk.BoomTest"; + let caps = CLOSE + .captures(line) + .expect("CLOSE must match an ERROR!-marked close line"); + assert_eq!(caps.get(1).expect("failures group").as_str(), "0"); + assert_eq!(caps.get(2).expect("errors group").as_str(), "1"); + } + + /// `mvn test` whose compile step fails before Surefire runs must still + /// keep the `[ERROR]` block's indented `symbol:` / `location:` continuation + /// lines. Regression guard for the P0 reviewer ask: `filter_surefire` + /// previously dropped them because it had no `keep_continuation` flag. + #[test] + fn surefire_keeps_compile_continuation_on_test_phase() { + let i = include_str!("../../../tests/fixtures/mvn_test_compile_fail_slice_raw.txt"); + let o = filter_surefire(i); + assert!(o.contains("cannot find symbol"), "ERROR line preserved; got:\n{}", o); + assert!( + o.contains("symbol: variable bar"), + "indented `symbol:` continuation preserved; got:\n{}", + o + ); + assert!( + o.contains("location: class org.apache.commons.cli.CompileBreaker"), + "indented `location:` continuation preserved; got:\n{}", + o + ); + assert!(o.contains("BUILD FAILURE"), "footer preserved; got:\n{}", o); + } + + /// Regression guard on the package path so the install/verify route does + /// not silently drift the other way after the `filter_surefire` continuation + /// fix. Uses the existing compile-error slice — `filter_package` is the + /// `install`-phase entry point and must keep the same continuation lines. + #[test] + fn package_still_keeps_compile_error_continuation_after_refactor() { + let i = include_str!("../../../tests/fixtures/mvn_compile_error_slice_raw.txt"); + let o = filter_package(i); + assert!(o.contains("cannot find symbol"), "ERROR line preserved; got:\n{}", o); + assert!( + o.contains("symbol: variable bar"), + "indented `symbol:` continuation preserved; got:\n{}", + o + ); + assert!( + o.contains("location: class org.apache.commons.cli.CompileBreaker"), + "indented `location:` continuation preserved; got:\n{}", + o + ); + } + + #[test] + fn surefire_keeps_module_banner() { + let i = "[INFO] Scanning for projects...\n[INFO] -----< com.example:myapp >-----\n[INFO] BUILD SUCCESS\n"; + let o = filter_surefire(i); + assert!(o.contains("-----< com.example:myapp >-----")); + } + + /// Production must ship raw `Time elapsed` and `Total time` durations + /// untouched — the LLM/user needs the actual numbers to diagnose perf + /// regressions. Earlier revisions normalised these to `T s`; that was + /// only ever needed for deterministic snapshots and never belonged in + /// the production path. + #[test] + fn surefire_preserves_real_durations() { + let i = "[INFO] -----< x >-----\n[INFO] Running x.Foo\n[ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 2.341 s <<< FAILURE! - in x.Foo\n[INFO] BUILD FAILURE\n[INFO] Total time: 4.567 s\n"; + let o = filter_surefire(i); + assert!( + o.contains("2.341 s"), + "raw close-line duration preserved; got:\n{}", + o + ); + assert!( + o.contains("Total time: 4.567 s"), + "raw total time preserved; got:\n{}", + o + ); + assert!( + !o.contains("Time elapsed: T s"), + "no normalisation in production; got:\n{}", + o + ); + } + + #[test] + fn footer_guard_french_passthrough() { + let i = include_str!("../../../tests/fixtures/mvn_locale_fr_raw.txt"); + let o = filter_surefire(i); + assert!( + o.contains("BUILD ÉCHEC"), + "footer-guard must pass through non-English output; got:\n{}", + o + ); + // Confirm we did NOT filter — input length preserved (modulo ANSI strip, which is a no-op here) + assert_eq!( + o.lines().count(), + i.lines().count(), + "footer-guard returns raw input" + ); + } + + #[test] + fn footer_guard_no_pom_passthrough() { + let i = include_str!("../../../tests/fixtures/mvn_no_pom_raw.txt"); + let o = filter_surefire(i); + // No BUILD footer → passthrough; user sees the `[ERROR] no POM` line. + assert!( + o.contains("there is no POM"), + "no-pom error preserved; got:\n{}", + o + ); + } + + // ── CRLF line-ending compatibility ─────────────────────────────────────── + + /// `str::lines()` strips single `\r\n` pairs entirely, so real Maven CRLF + /// output filters cleanly. The hazard is a fixture *already checked out + /// with CRLF* (e.g. `core.autocrlf=true` without `.gitattributes`): the + /// `\n` → `\r\n` synthesis below would then produce `\r\r\n`, leaving a + /// stray `\r` per line that `$`-anchored regexes reject. Normalise the + /// embedded fixture back to LF first — correct regardless of checkout + /// state (defense-in-depth alongside `tests/fixtures/** -text`). + #[test] + fn surefire_handles_crlf_line_endings() { + let i_lf = include_str!("../../../tests/fixtures/mvn_test_pass_slice_raw.txt") + .replace("\r\n", "\n"); + let o_lf = filter_surefire(&i_lf); + let i_crlf = i_lf.replace('\n', "\r\n"); + let o_crlf = filter_surefire(&i_crlf); + assert_eq!( + o_lf, + o_crlf.replace("\r\n", "\n"), + "CRLF filtered output must match LF (modulo line endings)" + ); + } + + #[test] + fn package_handles_crlf_line_endings() { + let i_lf = include_str!("../../../tests/fixtures/mvn_install_slice_raw.txt") + .replace("\r\n", "\n"); + let o_lf = filter_package(&i_lf); + let i_crlf = i_lf.replace('\n', "\r\n"); + let o_crlf = filter_package(&i_crlf); + assert_eq!( + o_lf, + o_crlf.replace("\r\n", "\n"), + "CRLF filtered output must match LF (modulo line endings)" + ); + } + + // ── Cap: failing-class blocks ──────────────────────────────────────────── + + /// Synthetic fixture with 5 failing classes; with `cap = 3` we expect + /// the first 3 failing blocks emitted in full and a + /// `… +2 more failing test classes` tail. + #[test] + fn surefire_caps_failing_blocks_emits_tail() { + let mut i = String::from( + "[INFO] Scanning for projects...\n\ + [INFO] -----< x >-----\n", + ); + for n in 1..=5 { + i.push_str(&format!( + "[INFO] Running x.Fail{n}\n\ + [ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.0{n}1 s <<< FAILURE! -- in x.Fail{n}\n\ + [ERROR] x.Fail{n}.bar -- Time elapsed: 0.0{n}0 s <<< FAILURE!\n\ + org.opentest4j.AssertionFailedError: boom{n}\n\ + \tat x.Fail{n}.bar(Fail{n}.java:25)\n\ + \n", + n = n + )); + } + i.push_str("[INFO] BUILD FAILURE\n"); + + let o = filter_surefire_with_cap(&i, 3); + + // First 3 blocks emitted with their close lines. + for n in 1..=3 { + assert!( + o.contains(&format!("Running x.Fail{}", n)), + "Fail{n} kept; got:\n{}", + o, + n = n + ); + assert!( + o.contains(&format!("in x.Fail{}", n)), + "Fail{n} close line kept; got:\n{}", + o, + n = n + ); + } + // Blocks 4 and 5 dropped. + for n in 4..=5 { + assert!( + !o.contains(&format!("Running x.Fail{}", n)), + "Fail{n} dropped; got:\n{}", + o, + n = n + ); + assert!( + !o.contains(&format!("AssertionFailedError: boom{}", n)), + "Fail{n} exception dropped; got:\n{}", + o, + n = n + ); + } + assert!( + o.contains("… +2 more failing test classes"), + "tail emitted; got:\n{}", + o + ); + } + + /// Cap of 0 means summary-only (core cap policy): no failing-class blocks + /// emitted, tail still counts every dropped class. + #[test] + fn surefire_cap_zero_emits_summary_only() { + let mut i = String::from( + "[INFO] Scanning for projects...\n\ + [INFO] -----< x >-----\n", + ); + for n in 1..=5 { + i.push_str(&format!( + "[INFO] Running x.Fail{n}\n\ + [ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.0{n}1 s <<< FAILURE! -- in x.Fail{n}\n\ + \n", + n = n + )); + } + i.push_str("[INFO] BUILD FAILURE\n"); + let o = filter_surefire_with_cap(&i, 0); + for n in 1..=5 { + assert!( + !o.contains(&format!("Running x.Fail{}", n)), + "Fail{n} dropped under cap=0; got:\n{}", + o, + n = n + ); + } + assert!( + o.contains("+5 more failing test classes"), + "tail counts all 5 under cap=0; got:\n{}", + o + ); + } + + /// `[ERROR] Failures:` summary block cap: with N>cap entries, expect the + /// first `cap` entries plus a `\n… +(N-cap) more failures\n` tail + /// emitted before the aggregate `[ERROR] Tests run:` line. + #[test] + fn failures_summary_block_is_capped() { + let mut i = String::from( + "[INFO] -----< x >-----\n\ + [INFO] Results:\n\ + [INFO]\n\ + [ERROR] Failures:\n", + ); + for n in 1..=5 { + i.push_str(&format!( + "[ERROR] ClassA.test{n}:25 expected: but was: \n", + n = n + )); + } + i.push_str( + "[INFO]\n\ + [ERROR] Tests run: 100, Failures: 5, Errors: 0, Skipped: 0\n\ + [INFO] BUILD FAILURE\n", + ); + let o = filter_surefire_with_cap(&i, 3); + + // First 3 entries kept. + for n in 1..=3 { + assert!( + o.contains(&format!("ClassA.test{}:25", n)), + "entry {n} kept; got:\n{}", + o, + n = n + ); + } + // Entries 4-5 dropped. + for n in 4..=5 { + assert!( + !o.contains(&format!("ClassA.test{}:25", n)), + "entry {n} dropped; got:\n{}", + o, + n = n + ); + } + // Tail emitted before aggregate. + let tail_idx = o + .find("… +2 more failures") + .unwrap_or_else(|| panic!("tail must appear; got:\n{}", o)); + let agg_idx = o + .find("[ERROR] Tests run: 100") + .unwrap_or_else(|| panic!("aggregate must appear; got:\n{}", o)); + assert!( + tail_idx < agg_idx, + "tail must precede aggregate; tail@{} agg@{}; got:\n{}", + tail_idx, + agg_idx, + o + ); + } + + // ── Multi-module reactor summary ───────────────────────────────────────── + + /// `mvn install` on a multi-module reactor build that passes everywhere + /// must preserve the `Reactor Summary for ` header and the per-module + /// `[INFO] foo ...... SUCCESS [ 1.234 s]` rows. + #[test] + fn reactor_summary_kept_on_multi_module_pass() { + let i = include_str!("../../../tests/fixtures/mvn_reactor_pass_slice_raw.txt"); + let o = filter_package(i); + assert!( + o.contains("Reactor Summary for multi-module-skeleton"), + "reactor summary header preserved; got:\n{}", + o + ); + assert!( + o.contains("[INFO] child-a ............................................ SUCCESS"), + "per-module SUCCESS row preserved; got:\n{}", + o + ); + assert!( + o.contains("[INFO] child-b ............................................ SUCCESS"), + "second per-module SUCCESS row preserved; got:\n{}", + o + ); + assert!( + o.contains("BUILD SUCCESS"), + "footer preserved; got:\n{}", + o + ); + } + + /// `mvn install` on a multi-module reactor build where one module fails + /// must preserve the Reactor Summary with the `FAILURE` row plus the + /// `[ERROR] Failed to execute goal …` terminator that already survives + /// via `keep_outside_block`. + #[test] + fn reactor_summary_kept_on_multi_module_fail() { + let i = include_str!("../../../tests/fixtures/mvn_reactor_fail_slice_raw.txt"); + let o = filter_package(i); + assert!( + o.contains("Reactor Summary for multi-module-skeleton"), + "reactor summary header preserved; got:\n{}", + o + ); + assert!( + o.contains("child-a ............................................ SUCCESS"), + "successful module row preserved; got:\n{}", + o + ); + assert!( + o.contains("child-b ............................................ FAILURE"), + "failing module row preserved; got:\n{}", + o + ); + assert!(o.contains("BUILD FAILURE"), "footer preserved; got:\n{}", o); + assert!( + o.contains("[ERROR] Failed to execute goal"), + "goal terminator preserved; got:\n{}", + o + ); + assert!( + o.contains("mvn -rf :child-b"), + "resume hint preserved (actionable signal); got:\n{}", + o + ); + assert!(!o.contains("[Help 1]"), "help boilerplate stripped; got:\n{}", o); + assert!( + !o.contains("Re-run Maven"), + "re-run hint stripped; got:\n{}", + o + ); + let savings = 100.0 - (count_tokens(&o) as f64 / count_tokens(i) as f64 * 100.0); + assert!( + savings >= 30.0, + "reactor-fail slice savings >=30% (short fixture); got {:.1}%", + savings + ); + } + + // ── Compile filter ─────────────────────────────────────────────────────── + + #[test] + fn filter_compile_error_compact() { + let i = include_str!("../../../tests/fixtures/mvn_compile_error_slice_raw.txt"); + let o = filter_compile(i); + let savings = 100.0 - (count_tokens(&o) as f64 / count_tokens(i) as f64 * 100.0); + assert!( + savings >= 30.0, + "compile-error fixture is small; >=30% savings, got {:.1}%", + savings + ); + } + + #[test] + fn compile_preserves_error_continuation() { + let i = include_str!("../../../tests/fixtures/mvn_compile_error_slice_raw.txt"); + let o = filter_compile(i); + assert!(o.contains("cannot find symbol"), "ERROR line preserved"); + assert!( + o.contains("symbol: variable bar"), + "indented continuation preserved" + ); + assert!(o.contains("BUILD FAILURE"), "footer preserved"); + assert!( + !o.contains("[Help 1]"), + "help boilerplate stripped in compile path; got:\n{}", + o + ); + } + + #[test] + fn compile_dedupes_warnings() { + let i = "[INFO] -----< x >-----\n\ + [WARNING] /a.java:[1,2] uses deprecated API\n\ + [WARNING] /b.java:[3,4] uses deprecated API\n\ + [WARNING] /a.java:[5,6] unchecked cast\n\ + [INFO] BUILD SUCCESS\n"; + let o = filter_compile(i); + let warns = o.matches("[WARNING]").count(); + assert_eq!(warns, 2, "dedup by normalised message; got:\n{}", o); + } + + // ── Package filter ─────────────────────────────────────────────────────── + + #[test] + fn filter_package_install_compact() { + let i = include_str!("../../../tests/fixtures/mvn_install_slice_raw.txt"); + let o = filter_package(i); + let savings = 100.0 - (count_tokens(&o) as f64 / count_tokens(i) as f64 * 100.0); + assert!( + savings >= 50.0, + "install-slice savings >=50%, got {:.1}%", + savings + ); + } + + #[test] + fn package_keeps_install_lines() { + let i = include_str!("../../../tests/fixtures/mvn_install_slice_raw.txt"); + let o = filter_package(i); + assert!( + o.contains("Installing"), + "install line preserved; got:\n{}", + o + ); + assert!( + o.contains("Building jar:"), + "jar line preserved; got:\n{}", + o + ); + assert!( + !o.contains("at org.junit."), + "framework frames stripped; got:\n{}", + o + ); + } + + // ── Token-savings (FULL gzipped fixtures) ─────────────────────────────── + + #[test] + #[ignore] + fn print_savings_summary() { + let pf = gunzip(include_bytes!("../../../tests/fixtures/mvn_test_pass_full_raw.txt.gz")); + let pf_out = filter_surefire(&pf); + let pf_in_tok = count_tokens(&pf); + let pf_out_tok = count_tokens(&pf_out); + let pf_s = 100.0 - (pf_out_tok as f64 / pf_in_tok as f64 * 100.0); + println!( + "mvn_test_pass_full: {} -> {} tokens ({:.1}% savings)", + pf_in_tok, pf_out_tok, pf_s + ); + + let inst = gunzip(include_bytes!("../../../tests/fixtures/mvn_install_full_raw.txt.gz")); + let inst_out = filter_package(&inst); + let inst_in_tok = count_tokens(&inst); + let inst_out_tok = count_tokens(&inst_out); + let inst_s = 100.0 - (inst_out_tok as f64 / inst_in_tok as f64 * 100.0); + println!( + "mvn_install_full: {} -> {} tokens ({:.1}% savings)", + inst_in_tok, inst_out_tok, inst_s + ); + } + + #[test] + fn savings_mvn_test_pass_full() { + let bytes = include_bytes!("../../../tests/fixtures/mvn_test_pass_full_raw.txt.gz"); + let i = gunzip(bytes); + let o = filter_surefire(&i); + let savings = 100.0 - (count_tokens(&o) as f64 / count_tokens(&i) as f64 * 100.0); + assert!( + savings >= 90.0, + "mvn test ≥90% savings on full fixture, got {:.1}% (raw={} tok, filtered={} tok)", + savings, + count_tokens(&i), + count_tokens(&o) + ); + } + + #[test] + fn savings_mvn_install_full() { + let bytes = include_bytes!("../../../tests/fixtures/mvn_install_full_raw.txt.gz"); + let i = gunzip(bytes); + let o = filter_package(&i); + let savings = 100.0 - (count_tokens(&o) as f64 / count_tokens(&i) as f64 * 100.0); + assert!( + savings >= 85.0, + "mvn install ≥85% savings on full fixture, got {:.1}% (raw={} tok, filtered={} tok)", + savings, + count_tokens(&i), + count_tokens(&o) + ); + } + + // ── Quiet mode (`mvn -q`) ──────────────────────────────────────────────── + + #[test] + fn quiet_detects_short_flag() { + assert!(is_quiet(&s(["-q", "test"]))); + assert!(is_quiet(&s(["test", "-q"]))); + assert!(is_quiet(&s(["-B", "-q", "-DskipFoo", "install"]))); + } + + #[test] + fn quiet_detects_long_flag() { + assert!(is_quiet(&s(["--quiet", "test"]))); + } + + #[test] + fn quiet_does_not_match_unrelated_flags() { + assert!(!is_quiet(&s(["-Q", "test"]))); + assert!(!is_quiet(&s(["-quiet", "test"]))); + assert!(!is_quiet(&s(["-B", "test"]))); + } + + /// Green `mvn -q test` emits zero bytes; filter must return empty. + #[test] + fn quiet_green_run_is_empty() { + assert_eq!(filter_quiet(""), ""); + assert_eq!(filter_quiet(" \n\n \n"), ""); + } + + /// Failure under `-q`: keep close-line, exception, user frame, summary, + /// goal terminator. Drop framework frames + help boilerplate block. + #[test] + fn quiet_fail_strips_framework_and_boilerplate() { + let i = include_str!("../../../tests/fixtures/mvn_quiet_fail_raw.txt"); + let o = filter_quiet(i); + + // Kept: failure signal. + assert!( + o.contains("Tests run: 1, Failures: 1, Errors: 0, Skipped: 0"), + "close-line preserved; got:\n{}", + o + ); + assert!( + o.contains("AssertionFailedError"), + "exception class preserved; got:\n{}", + o + ); + assert!( + o.contains("at x.FailTest.this_will_fail"), + "user-code frame preserved; got:\n{}", + o + ); + assert!( + o.contains("[ERROR] Failures:"), + "failure summary header preserved; got:\n{}", + o + ); + assert!( + o.contains("[ERROR] Tests run: 6, Failures: 1, Errors: 0, Skipped: 0"), + "aggregate preserved; got:\n{}", + o + ); + assert!( + o.contains("[ERROR] Failed to execute goal"), + "goal terminator preserved; got:\n{}", + o + ); + + // Dropped: framework frames. + assert!( + !o.contains("at org.junit."), + "junit frame stripped; got:\n{}", + o + ); + assert!( + !o.contains("at java.base/"), + "java.base frame stripped; got:\n{}", + o + ); + + // Dropped: help boilerplate. + assert!( + !o.contains("To see the full stack trace"), + "help boilerplate stripped; got:\n{}", + o + ); + assert!( + !o.contains("[Help 1] http"), + "help link stripped; got:\n{}", + o + ); + assert!( + !o.contains("See /tmp/") && !o.contains("See dump files"), + "log-pointer lines stripped; got:\n{}", + o + ); + } + + /// Savings target on the real `mvn -q test` fail fixture. + #[test] + fn savings_mvn_quiet_fail() { + let i = include_str!("../../../tests/fixtures/mvn_quiet_fail_raw.txt"); + let o = filter_quiet(i); + let savings = 100.0 - (count_tokens(&o) as f64 / count_tokens(i) as f64 * 100.0); + assert!( + savings >= 50.0, + "mvn -q fail ≥50% savings, got {:.1}% (raw={} tok, filtered={} tok)", + savings, + count_tokens(i), + count_tokens(&o) + ); + } + + /// Safety net: if the `[ERROR]` line isn't on the known keep/drop lists, + /// the filter must NOT silently drop it. Better to leak a line than to + /// hide signal. + #[test] + fn quiet_unknown_error_line_kept_as_safety_net() { + let i = "[ERROR] Some unexpected error output we don't classify\n"; + let o = filter_quiet(i); + assert!( + o.contains("Some unexpected error output"), + "unclassified ERROR line preserved; got:\n{}", + o + ); + } +} + + + diff --git a/src/cmds/python/mypy_cmd.rs b/src/cmds/python/mypy_cmd.rs index 69bc7173a..7c26cc9d4 100644 --- a/src/cmds/python/mypy_cmd.rs +++ b/src/cmds/python/mypy_cmd.rs @@ -164,7 +164,6 @@ pub fn filter_mypy_output(output: &str) -> String { errors.len(), by_file.len() )); - result.push_str("═══════════════════════════════════════\n"); // Top error codes summary (only when 2+ distinct codes) let mut code_counts: Vec<_> = by_code.iter().collect(); diff --git a/src/cmds/python/pip_cmd.rs b/src/cmds/python/pip_cmd.rs index 037dd58a4..9a4d00271 100644 --- a/src/cmds/python/pip_cmd.rs +++ b/src/cmds/python/pip_cmd.rs @@ -153,7 +153,6 @@ fn filter_pip_list(output: &str) -> String { let mut result = String::new(); result.push_str(&format!("pip list: {} packages\n", packages.len())); - result.push_str("═══════════════════════════════════════\n"); // Group by first letter for easier scanning let mut by_letter: std::collections::HashMap> = @@ -203,7 +202,6 @@ fn filter_pip_outdated(output: &str) -> String { let mut result = String::new(); result.push_str(&format!("pip outdated: {} packages\n", packages.len())); - result.push_str("═══════════════════════════════════════\n"); const MAX_PIP_PACKAGES: usize = CAP_LIST; for (i, pkg) in packages.iter().take(MAX_PIP_PACKAGES).enumerate() { diff --git a/src/cmds/python/pytest_cmd.rs b/src/cmds/python/pytest_cmd.rs index 2febb2ea1..87eca2ec9 100644 --- a/src/cmds/python/pytest_cmd.rs +++ b/src/cmds/python/pytest_cmd.rs @@ -202,7 +202,6 @@ fn build_pytest_summary( result.push_str(&format!(", {} xpassed", xpassed)); } result.push('\n'); - result.push_str("═══════════════════════════════════════\n"); // Surface xfail/xpass entries (with their reasons) — XPASS in particular // signals that something expected-to-fail now passes. diff --git a/src/cmds/python/ruff_cmd.rs b/src/cmds/python/ruff_cmd.rs index 3f240df13..f8a512035 100644 --- a/src/cmds/python/ruff_cmd.rs +++ b/src/cmds/python/ruff_cmd.rs @@ -144,7 +144,6 @@ pub fn filter_ruff_check_json(output: &str) -> String { result.push_str(&format!(" ({} fixable)", fixable_count)); } result.push('\n'); - result.push_str("═══════════════════════════════════════\n"); // Show top rules let mut rule_counts: Vec<_> = by_rule.iter().collect(); @@ -288,7 +287,6 @@ pub fn filter_ruff_format(output: &str) -> String { "Ruff format: {} files need formatting\n", files_to_format.len() )); - result.push_str("═══════════════════════════════════════\n"); const MAX_RUFF_FORMAT_FILES: usize = CAP_WARNINGS; for (i, file) in files_to_format diff --git a/src/cmds/ruby/rspec_cmd.rs b/src/cmds/ruby/rspec_cmd.rs index 80fbe1c10..bf907c37f 100644 --- a/src/cmds/ruby/rspec_cmd.rs +++ b/src/cmds/ruby/rspec_cmd.rs @@ -214,7 +214,6 @@ fn build_rspec_summary(rspec: &RspecOutput) -> String { 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 @@ -230,7 +229,7 @@ fn build_rspec_summary(rspec: &RspecOutput) -> String { for (i, example) in failures.iter().take(MAX_RSPEC_FAILURES).enumerate() { result.push_str(&format!( - "{}. ❌ {}\n {}:{}\n", + "{}. ✗ {}\n {}:{}\n", i + 1, example.full_description, example.file_path, @@ -353,9 +352,8 @@ fn filter_rspec_text(output: &str) -> String { 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(MAX_RSPEC_FAILURES).enumerate() { - result.push_str(&format!("{}. ❌ {}\n", i + 1, failure)); + result.push_str(&format!("{}. ✗ {}\n", i + 1, failure)); if i < failures.len().min(MAX_RSPEC_FAILURES) - 1 { result.push('\n'); } @@ -596,7 +594,7 @@ mod tests { 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 saves to database")); assert!(result.contains("user_spec.rb:10")); assert!(result.contains("ExpectationNotMetError")); assert!(result.contains("expected true but got false")); @@ -672,7 +670,7 @@ Failures: let result = filter_rspec_output(text); assert!(result.contains("RSpec:")); assert!(result.contains("4 examples, 1 failure")); - assert!(result.contains("❌"), "should show failure marker"); + assert!(result.contains("✗"), "should show failure marker"); } #[test] @@ -698,7 +696,7 @@ Failures: "#; let result = filter_rspec_text(text); assert!(result.contains("2 failures")); - assert!(result.contains("❌")); + assert!(result.contains("✗")); // Should show spec file path, not gem backtrace assert!(result.contains("spec/models/user_spec.rb:15")); } @@ -741,9 +739,9 @@ Failures: "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. ✗"), "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: {}", @@ -946,9 +944,9 @@ Failures: 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("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: {}", diff --git a/src/cmds/rust/cargo_cmd.rs b/src/cmds/rust/cargo_cmd.rs index 8726de40e..061b1bfbd 100644 --- a/src/cmds/rust/cargo_cmd.rs +++ b/src/cmds/rust/cargo_cmd.rs @@ -102,7 +102,7 @@ impl BlockHandler for CargoBuildHandler { Some(format!("{}\n", s)) } else { Some(format!( - "═══════════════════════════════════════\ncargo build: {} errors, {} warnings ({} crates)\n", + "cargo build: {} errors, {} warnings ({} crates)\n", self.error_count, self.warnings, self.compiled )) } @@ -488,7 +488,6 @@ fn filter_cargo_install(output: &str) -> String { deps_info )); } - result.push_str("═══════════════════════════════════════\n"); const MAX_INSTALL_ERRORS: usize = CAP_ERRORS; for (i, err) in errors.iter().enumerate().take(MAX_INSTALL_ERRORS) { @@ -796,7 +795,7 @@ fn filter_cargo_build(output: &str) -> String { } let mut result = format!( - "cargo build: {} errors, {} warnings ({} crates)\n═══════════════════════════════════════\n", + "cargo build: {} errors, {} warnings ({} crates)\n", handler.error_count, handler.warnings, handler.compiled ); const MAX_CHECK_BLOCKS: usize = CAP_ERRORS; @@ -1011,7 +1010,6 @@ pub(crate) fn filter_cargo_test(output: &str) -> String { if !failures.is_empty() { result.push_str(&format!("FAILURES ({}):\n", failures.len())); - result.push_str("═══════════════════════════════════════\n"); const MAX_FAILURES: usize = CAP_WARNINGS; for (i, failure) in failures.iter().enumerate().take(MAX_FAILURES) { result.push_str(&format!("{}. {}\n", i + 1, truncate(failure, 200))); @@ -1168,7 +1166,6 @@ fn filter_cargo_clippy(output: &str) -> String { "cargo clippy: {} errors, {} warnings\n", error_count, warning_count )); - result.push_str("═══════════════════════════════════════\n"); // Show full error blocks so developers can see what needs fixing if !error_blocks.is_empty() { diff --git a/src/cmds/system/format_cmd.rs b/src/cmds/system/format_cmd.rs index c71dd1072..431b25c78 100644 --- a/src/cmds/system/format_cmd.rs +++ b/src/cmds/system/format_cmd.rs @@ -235,7 +235,6 @@ fn filter_black_output(output: &str) -> String { "Format (black): {} files need formatting\n", count )); - result.push_str("═══════════════════════════════════════\n"); if !files_to_format.is_empty() { const MAX_FORMAT_FILES: usize = CAP_WARNINGS; diff --git a/src/core/toml_filter.rs b/src/core/toml_filter.rs index 06060d22d..74b61026d 100644 --- a/src/core/toml_filter.rs +++ b/src/core/toml_filter.rs @@ -1582,7 +1582,6 @@ match_command = "^make\\b" "markdownlint", "mix-compile", "mix-format", - "mvn-build", "ping", "pio-run", "poetry-install", @@ -1621,8 +1620,8 @@ match_command = "^make\\b" let filters = make_filters(BUILTIN_TOML); assert_eq!( filters.len(), - 59, - "Expected exactly 59 built-in filters, got {}. \ + 58, + "Expected exactly 58 built-in filters, got {}. \ Update this count when adding/removing filters in src/filters/.", filters.len() ); @@ -1679,11 +1678,11 @@ expected = "output line 1\noutput line 2" let combined = format!("{}\n\n{}", BUILTIN_TOML, new_filter); let filters = make_filters(&combined); - // All 59 existing filters still present + 1 new = 60 + // All 58 existing filters still present + 1 new = 59 assert_eq!( filters.len(), - 60, - "Expected 60 filters after concat (59 built-in + 1 new)" + 59, + "Expected 59 filters after concat (58 built-in + 1 new)" ); // New filter is discoverable diff --git a/src/discover/registry.rs b/src/discover/registry.rs index 4fd716828..59651a497 100644 --- a/src/discover/registry.rs +++ b/src/discover/registry.rs @@ -3096,6 +3096,135 @@ mod tests { ); } + // --- Maven --- + + #[test] + fn test_classify_mvn_test() { + assert!(matches!( + classify_command("mvn test"), + Classification::Supported { + rtk_equivalent: "rtk mvn", + .. + } + )); + } + + #[test] + fn test_classify_mvn_integration_test() { + assert!(matches!( + classify_command("mvn integration-test"), + Classification::Supported { + rtk_equivalent: "rtk mvn", + .. + } + )); + } + + #[test] + fn test_classify_mvn_flags_before_goal() { + assert!(matches!( + classify_command("mvn -B -DskipTests=false clean install"), + Classification::Supported { + rtk_equivalent: "rtk mvn", + .. + } + )); + } + + #[test] + fn test_classify_mvnw_wrapper() { + assert!(matches!( + classify_command("./mvnw verify"), + Classification::Supported { + rtk_equivalent: "rtk mvn", + .. + } + )); + } + + #[test] + fn test_classify_mvnw_cmd_wrapper() { + assert!(matches!( + classify_command("mvnw.cmd package"), + Classification::Supported { + rtk_equivalent: "rtk mvn", + .. + } + )); + } + + #[test] + fn test_classify_mvn_clean_bypassed() { + // `clean` deliberately excluded from the alternation to avoid 0-overhead fork. + assert!(!matches!( + classify_command("mvn clean"), + Classification::Supported { + rtk_equivalent: "rtk mvn", + .. + } + )); + } + + #[test] + fn test_classify_mvn_site_bypassed() { + assert!(!matches!( + classify_command("mvn site"), + Classification::Supported { + rtk_equivalent: "rtk mvn", + .. + } + )); + } + + #[test] + fn test_classify_mvn_plugin_goal_bypassed() { + assert!(!matches!( + classify_command("mvn dependency:tree"), + Classification::Supported { + rtk_equivalent: "rtk mvn", + .. + } + )); + } + + #[test] + fn test_classify_mvn_bare_bypassed() { + assert!(!matches!( + classify_command("mvn"), + Classification::Supported { + rtk_equivalent: "rtk mvn", + .. + } + )); + } + + #[test] + fn test_classify_mvn_version_bypassed() { + assert!(!matches!( + classify_command("mvn --version"), + Classification::Supported { + rtk_equivalent: "rtk mvn", + .. + } + )); + } + + #[test] + fn test_rewrite_mvn_clean_install() { + assert_eq!( + rewrite_command_no_prefixes("mvn -B clean install", &[]), + Some("rtk mvn -B clean install".into()) + ); + } + + #[test] + fn test_rewrite_mvnw_test() { + assert_eq!( + rewrite_command_no_prefixes("./mvnw test", &[]), + Some("rtk mvn test".into()) + ); + } + // --- Compound operator edge cases --- #[test] diff --git a/src/discover/rules.rs b/src/discover/rules.rs index df7c72d03..6bb5741c8 100644 --- a/src/discover/rules.rs +++ b/src/discover/rules.rs @@ -690,11 +690,11 @@ pub const RULES: &[RtkRule] = &[ subcmd_status: &[], }, RtkRule { - pattern: r"^mvn\s+(compile|package|clean|install)\b", + pattern: r"^(?:\./mvnw|mvnw\.cmd|mvnw|mvn)\b(?:\s+\S+)*?\s+(compile|test|integration-test|package|install|verify|deploy)\b", rtk_cmd: "rtk mvn", - rewrite_prefixes: &["mvn"], + rewrite_prefixes: &["./mvnw", "mvnw.cmd", "mvnw", "mvn"], category: "Build", - savings_pct: 70.0, + savings_pct: 82.0, subcmd_savings: &[], subcmd_status: &[], }, diff --git a/src/filters/helm.toml b/src/filters/helm.toml index 306607898..790ddd3ec 100644 --- a/src/filters/helm.toml +++ b/src/filters/helm.toml @@ -7,7 +7,6 @@ strip_lines_matching = [ "^W\\d{4}", ] truncate_lines_at = 120 -max_lines = 40 [[tests.helm]] name = "strips blank lines, preserves release info" @@ -27,3 +26,78 @@ expected = "NAME: my-release\nLAST DEPLOYED: Mon Jan 15 10:30:00 2024\nNAMESPACE name = "strips glog W-prefix warnings" input = "W0115 10:30:00 warning message from internal\nNAME: my-chart\nSTATUS: deployed" expected = "NAME: my-chart\nSTATUS: deployed" + +[[tests.helm]] +name = "helm template output is not truncated" +input = """ +# Source: my-chart/templates/namespace.yaml +apiVersion: v1 +kind: Namespace +metadata: + name: my-ns +--- +# Source: my-chart/templates/secret.yaml +apiVersion: v1 +kind: Secret +metadata: + name: my-secret +--- +# Source: my-chart/templates/configmap.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: my-cm +--- +# Source: my-chart/templates/pvc.yaml +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: my-pvc +--- +# Source: my-chart/templates/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: my-svc +--- +# Source: my-chart/templates/deployment.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: my-deploy +""" +expected = """# Source: my-chart/templates/namespace.yaml +apiVersion: v1 +kind: Namespace +metadata: + name: my-ns +--- +# Source: my-chart/templates/secret.yaml +apiVersion: v1 +kind: Secret +metadata: + name: my-secret +--- +# Source: my-chart/templates/configmap.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: my-cm +--- +# Source: my-chart/templates/pvc.yaml +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: my-pvc +--- +# Source: my-chart/templates/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: my-svc +--- +# Source: my-chart/templates/deployment.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: my-deploy""" diff --git a/src/filters/mvn-build.toml b/src/filters/mvn-build.toml deleted file mode 100644 index 430a7f0cd..000000000 --- a/src/filters/mvn-build.toml +++ /dev/null @@ -1,44 +0,0 @@ -[filters.mvn-build] -description = "Compact Maven build output" -match_command = "^mvn\\s+(compile|package|clean|install)\\b" -strip_ansi = true -strip_lines_matching = [ - "^\\[INFO\\] ---", - "^\\[INFO\\] Building\\s", - "^\\[INFO\\] Downloading\\s", - "^\\[INFO\\] Downloaded\\s", - "^\\[INFO\\]\\s*$", - "^\\s*$", - "^Downloading:", - "^Downloaded:", - "^Progress", -] -max_lines = 50 -on_empty = "mvn: ok" - -[[tests.mvn-build]] -name = "strips INFO noise, preserves errors and summary" -input = """ -[INFO] --- -[INFO] Building myapp 1.0-SNAPSHOT -[INFO] Downloading org.apache.maven.plugins:maven-compiler-plugin:3.11.0 -[INFO] Downloaded org.apache.maven.plugins:maven-compiler-plugin:3.11.0 -[INFO] -[ERROR] /src/main/java/Main.java:[10,5] cannot find symbol - symbol: method foo() -[INFO] BUILD FAILURE -[INFO] Total time: 2.543 s -""" -expected = "[ERROR] /src/main/java/Main.java:[10,5] cannot find symbol\n symbol: method foo()\n[INFO] BUILD FAILURE\n[INFO] Total time: 2.543 s" - -[[tests.mvn-build]] -name = "successful build keeps BUILD SUCCESS line" -input = """ -[INFO] --- -[INFO] Building myapp 1.0-SNAPSHOT -[INFO] -[INFO] BUILD SUCCESS -[INFO] Total time: 4.123 s -[INFO] Finished at: 2024-01-15T10:30:00Z -""" -expected = "[INFO] BUILD SUCCESS\n[INFO] Total time: 4.123 s\n[INFO] Finished at: 2024-01-15T10:30:00Z" diff --git a/src/main.rs b/src/main.rs index 992f865a2..a5bde544a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,7 +15,7 @@ use cmds::js::{ lint_cmd, next_cmd, npm_cmd, playwright_cmd, pnpm_cmd, prettier_cmd, prisma_cmd, tsc_cmd, vitest_cmd, }; -use cmds::jvm::gradlew_cmd; +use cmds::jvm::{gradlew_cmd, mvn_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}; @@ -736,6 +736,14 @@ enum Commands { args: Vec, }, + /// Apache Maven wrapper with compact output (test, integration-test, compile, package, install, verify, deploy) + #[command(name = "mvn")] + Mvn { + /// Maven goals and arguments (e.g., clean install, -DskipTests test, -X) + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Show hook rewrite audit metrics (requires RTK_HOOK_AUDIT=1) #[command(name = "hook-audit")] HookAudit { @@ -2190,6 +2198,8 @@ fn run_cli() -> Result { Commands::Gradlew { args } => gradlew_cmd::run(&args, cli.verbose)?, + Commands::Mvn { args } => mvn_cmd::run(&args, cli.verbose)?, + Commands::HookAudit { since } => { hooks::hook_audit_cmd::run(since, cli.verbose)?; 0 diff --git a/tests/fixtures/aws_backup_describe_global_settings.json b/tests/fixtures/aws_backup_describe_global_settings.json new file mode 100644 index 000000000..8a521c647 --- /dev/null +++ b/tests/fixtures/aws_backup_describe_global_settings.json @@ -0,0 +1,8 @@ +{ + "GlobalSettings": { + "isCrossAccountBackupEnabled": "false", + "isDelegatedAdministratorEnabled": "false", + "isMpaEnabled": "false" + }, + "LastUpdateTime": "2026-05-28T09:52:17.525000+02:00" +} diff --git a/tests/fixtures/multi-module-skeleton/child-a/pom.xml b/tests/fixtures/multi-module-skeleton/child-a/pom.xml new file mode 100644 index 000000000..8aeb11df9 --- /dev/null +++ b/tests/fixtures/multi-module-skeleton/child-a/pom.xml @@ -0,0 +1,11 @@ + + + 4.0.0 + + com.example.rtk + multi-module-skeleton + 1.0.0-SNAPSHOT + + child-a + jar + diff --git a/tests/fixtures/multi-module-skeleton/child-a/src/main/java/Empty.java b/tests/fixtures/multi-module-skeleton/child-a/src/main/java/Empty.java new file mode 100644 index 000000000..9a9956e0e --- /dev/null +++ b/tests/fixtures/multi-module-skeleton/child-a/src/main/java/Empty.java @@ -0,0 +1 @@ +public class Empty {} diff --git a/tests/fixtures/multi-module-skeleton/child-b/pom.xml b/tests/fixtures/multi-module-skeleton/child-b/pom.xml new file mode 100644 index 000000000..3ea22fc11 --- /dev/null +++ b/tests/fixtures/multi-module-skeleton/child-b/pom.xml @@ -0,0 +1,11 @@ + + + 4.0.0 + + com.example.rtk + multi-module-skeleton + 1.0.0-SNAPSHOT + + child-b + jar + diff --git a/tests/fixtures/multi-module-skeleton/child-b/src/main/java/Empty.java b/tests/fixtures/multi-module-skeleton/child-b/src/main/java/Empty.java new file mode 100644 index 000000000..9a9956e0e --- /dev/null +++ b/tests/fixtures/multi-module-skeleton/child-b/src/main/java/Empty.java @@ -0,0 +1 @@ +public class Empty {} diff --git a/tests/fixtures/multi-module-skeleton/pom.xml b/tests/fixtures/multi-module-skeleton/pom.xml new file mode 100644 index 000000000..a40a3c786 --- /dev/null +++ b/tests/fixtures/multi-module-skeleton/pom.xml @@ -0,0 +1,17 @@ + + + 4.0.0 + com.example.rtk + multi-module-skeleton + 1.0.0-SNAPSHOT + pom + + child-a + child-b + + + 21 + 21 + UTF-8 + + diff --git a/tests/fixtures/multifail-skeleton/pom.xml b/tests/fixtures/multifail-skeleton/pom.xml new file mode 100644 index 000000000..58081295d --- /dev/null +++ b/tests/fixtures/multifail-skeleton/pom.xml @@ -0,0 +1,30 @@ + + + 4.0.0 + com.example.rtk + multifail-skeleton + 1.0.0-SNAPSHOT + jar + + 21 + 21 + UTF-8 + + + + org.junit.jupiter + junit-jupiter + 5.11.4 + test + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.5 + + + + diff --git a/tests/fixtures/multifail-skeleton/src/main/java/com/example/rtk/Calc.java b/tests/fixtures/multifail-skeleton/src/main/java/com/example/rtk/Calc.java new file mode 100644 index 000000000..26090c28e --- /dev/null +++ b/tests/fixtures/multifail-skeleton/src/main/java/com/example/rtk/Calc.java @@ -0,0 +1,7 @@ +package com.example.rtk; + +public class Calc { + public int add(int a, int b) { + return a + b; + } +} diff --git a/tests/fixtures/multifail-skeleton/src/test/java/com/example/rtk/BoomTest.java b/tests/fixtures/multifail-skeleton/src/test/java/com/example/rtk/BoomTest.java new file mode 100644 index 000000000..ea5e9720f --- /dev/null +++ b/tests/fixtures/multifail-skeleton/src/test/java/com/example/rtk/BoomTest.java @@ -0,0 +1,10 @@ +package com.example.rtk; + +import org.junit.jupiter.api.Test; + +class BoomTest { + @Test + void boom() { + throw new IllegalStateException("boom: errors-only class for fixture capture"); + } +} diff --git a/tests/fixtures/multifail-skeleton/src/test/java/com/example/rtk/CalcTest.java b/tests/fixtures/multifail-skeleton/src/test/java/com/example/rtk/CalcTest.java new file mode 100644 index 000000000..eecf368d2 --- /dev/null +++ b/tests/fixtures/multifail-skeleton/src/test/java/com/example/rtk/CalcTest.java @@ -0,0 +1,24 @@ +package com.example.rtk; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +class CalcTest { + private final Calc calc = new Calc(); + + @Test + void failOne() { + assertEquals(5, calc.add(2, 2), "failOne: addition should equal five"); + } + + @Test + void failTwo() { + throw new IllegalStateException("failTwo: induced error for fixture capture"); + } + + @Test + void passes() { + assertEquals(4, calc.add(2, 2)); + } +} diff --git a/tests/fixtures/mvn_clean_raw.txt b/tests/fixtures/mvn_clean_raw.txt new file mode 100644 index 000000000..1cdcecbe1 --- /dev/null +++ b/tests/fixtures/mvn_clean_raw.txt @@ -0,0 +1,15 @@ +[INFO] Scanning for projects... +[INFO] +[INFO] -----------------------< com.example:myapp >------------------------ +[INFO] Building myapp 1.0.0 +[INFO] from pom.xml +[INFO] --------------------------------[ jar ]--------------------------------- +[INFO] +[INFO] --- clean:3.2.0:clean (default-clean) @ myapp --- +[INFO] Deleting /path/to/project/target +[INFO] ------------------------------------------------------------------------ +[INFO] BUILD SUCCESS +[INFO] ------------------------------------------------------------------------ +[INFO] Total time: 0.853 s +[INFO] Finished at: 2026-01-15T10:30:00Z +[INFO] ------------------------------------------------------------------------ diff --git a/tests/fixtures/mvn_compile_error_slice_raw.txt b/tests/fixtures/mvn_compile_error_slice_raw.txt new file mode 100644 index 000000000..bffb7bb9f --- /dev/null +++ b/tests/fixtures/mvn_compile_error_slice_raw.txt @@ -0,0 +1,33 @@ +[INFO] Scanning for projects... +[INFO] +[INFO] ----------------------< commons-cli:commons-cli >----------------------- +[INFO] Building Apache Commons CLI 1.11.1-SNAPSHOT +[INFO] from pom.xml +[INFO] --------------------------------[ jar ]--------------------------------- +[INFO] +[INFO] --- compiler:3.15.0:compile (default-compile) @ commons-cli --- +[INFO] Recompiling the module because of changed source code. +[INFO] Compiling 37 source files with javac [debug release 8] to target/classes +[INFO] ------------------------------------------------------------- +[WARNING] COMPILATION WARNING : +[INFO] ------------------------------------------------------------- +[WARNING] source value 8 is obsolete and will be removed in a future release +[WARNING] target value 8 is obsolete and will be removed in a future release +[WARNING] To suppress warnings about obsolete options, use -Xlint:-options. +[INFO] 3 warnings +[INFO] ------------------------------------------------------------- +[INFO] ------------------------------------------------------------- +[ERROR] COMPILATION ERROR : +[INFO] ------------------------------------------------------------- +[ERROR] src/main/java/org/apache/commons/cli/CompileBreaker.java:[21,16] cannot find symbol + symbol: variable bar + location: class org.apache.commons.cli.CompileBreaker +[INFO] 1 error +[INFO] ------------------------------------------------------------- +[INFO] ------------------------------------------------------------------------ +[INFO] BUILD FAILURE +[INFO] ------------------------------------------------------------------------ +[INFO] Total time: 30.631 s +[INFO] Finished at: 2026-05-21T14:59:46Z +[INFO] ------------------------------------------------------------------------ +[ERROR] Failed to execute goal org.apache.maven.plugins:maven-compiler-plugin:3.15.0:compile (default-compile) on project commons-cli: Compilation failure diff --git a/tests/fixtures/mvn_install_full_raw.txt.gz b/tests/fixtures/mvn_install_full_raw.txt.gz new file mode 100644 index 000000000..eb894cfcf Binary files /dev/null and b/tests/fixtures/mvn_install_full_raw.txt.gz differ diff --git a/tests/fixtures/mvn_install_slice_raw.txt b/tests/fixtures/mvn_install_slice_raw.txt new file mode 100644 index 000000000..c4bb76cfd --- /dev/null +++ b/tests/fixtures/mvn_install_slice_raw.txt @@ -0,0 +1,44 @@ +[INFO] Scanning for projects... +[INFO] +[INFO] ----------------------< commons-cli:commons-cli >----------------------- +[INFO] Building Apache Commons CLI 1.11.1-SNAPSHOT +[INFO] from pom.xml +[INFO] --------------------------------[ jar ]--------------------------------- +[INFO] +[INFO] --- compiler:3.15.0:compile (default-compile) @ commons-cli --- +[INFO] Recompiling the module because of changed source code. +[INFO] Compiling 47 source files with javac [debug parameters release 11] to target/classes +[INFO] +[INFO] --- compiler:3.15.0:testCompile (default-testCompile) @ commons-cli --- +[INFO] Recompiling the module because of changed dependency. +[INFO] Compiling 52 source files with javac [debug parameters release 11] to target/test-classes +[INFO] +[INFO] --- surefire:3.5.5:test (default-test) @ commons-cli --- +[INFO] +[INFO] ------------------------------------------------------- +[INFO] T E S T S +[INFO] ------------------------------------------------------- +[INFO] Running org.apache.commons.cli.help.UtilTest +[INFO] Tests run: 34, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.023 s -- in org.apache.commons.cli.help.UtilTest +[INFO] Running org.apache.commons.cli.DefaultParserTest +[WARNING] Tests run: 87, Failures: 0, Errors: 0, Skipped: 2, Time elapsed: 1.023 s -- in org.apache.commons.cli.DefaultParserTest +[INFO] Running org.apache.commons.cli.AlreadySelectedExceptionTest +[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.014 s -- in org.apache.commons.cli.AlreadySelectedExceptionTest +[INFO] +[INFO] Results: +[INFO] +[INFO] Tests run: 977, Failures: 0, Errors: 0, Skipped: 61 +[INFO] +[INFO] --- jar:3.5.0:jar (default-jar) @ commons-cli --- +[INFO] Building jar: target/commons-cli-1.11.1-SNAPSHOT.jar +[INFO] +[INFO] --- install:3.1.4:install (default-install) @ commons-cli --- +[INFO] Installing pom.xml to /root/.m2/repository/commons-cli/commons-cli/1.11.1-SNAPSHOT/commons-cli-1.11.1-SNAPSHOT.pom +[INFO] Installing target/commons-cli-1.11.1-SNAPSHOT.jar to /root/.m2/repository/commons-cli/commons-cli/1.11.1-SNAPSHOT/commons-cli-1.11.1-SNAPSHOT.jar +[INFO] Installing target/commons-cli-1.11.1-SNAPSHOT-sources.jar to /root/.m2/repository/commons-cli/commons-cli/1.11.1-SNAPSHOT/commons-cli-1.11.1-SNAPSHOT-sources.jar +[INFO] ------------------------------------------------------------------------ +[INFO] BUILD SUCCESS +[INFO] ------------------------------------------------------------------------ +[INFO] Total time: 01:29 min +[INFO] Finished at: 2026-05-21T15:13:00Z +[INFO] ------------------------------------------------------------------------ diff --git a/tests/fixtures/mvn_locale_fr_raw.txt b/tests/fixtures/mvn_locale_fr_raw.txt new file mode 100644 index 000000000..ab6628439 --- /dev/null +++ b/tests/fixtures/mvn_locale_fr_raw.txt @@ -0,0 +1,15 @@ +[INFO] Recherche des projets... +[INFO] +[INFO] -----------------------< com.example:myapp >------------------------ +[INFO] Construction de myapp 1.0.0 +[INFO] de pom.xml +[INFO] --------------------------------[ jar ]--------------------------------- +[INFO] +[INFO] --- clean:3.2.0:clean (default-clean) @ myapp --- +[INFO] Suppression de /path/to/project/target +[INFO] ------------------------------------------------------------------------ +[INFO] BUILD ÉCHEC +[INFO] ------------------------------------------------------------------------ +[INFO] Temps total: 0.713 s +[INFO] Termine a: 2026-01-15T10:30:00Z +[INFO] ------------------------------------------------------------------------ diff --git a/tests/fixtures/mvn_no_pom_raw.txt b/tests/fixtures/mvn_no_pom_raw.txt new file mode 100644 index 000000000..ae838f4ca --- /dev/null +++ b/tests/fixtures/mvn_no_pom_raw.txt @@ -0,0 +1,8 @@ +[INFO] Scanning for projects... +[ERROR] The goal you specified requires a project to execute but there is no POM in this directory (/tmp). Please verify you invoked Maven from the correct directory. -> [Help 1] +[ERROR] +[ERROR] To see the full stack trace of the errors, re-run Maven with the -e switch. +[ERROR] Re-run Maven using the -X switch to enable full debug logging. +[ERROR] +[ERROR] For more information about the errors and possible solutions, please read the following articles: +[ERROR] [Help 1] http://cwiki.apache.org/confluence/display/MAVEN/MissingProjectException diff --git a/tests/fixtures/mvn_quiet_fail_raw.txt b/tests/fixtures/mvn_quiet_fail_raw.txt new file mode 100644 index 000000000..ef5ecfbaf --- /dev/null +++ b/tests/fixtures/mvn_quiet_fail_raw.txt @@ -0,0 +1,27 @@ +[ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.069 s <<< FAILURE! -- in x.FailTest +[ERROR] x.FailTest.this_will_fail -- Time elapsed: 0.030 s <<< FAILURE! +org.opentest4j.AssertionFailedError: deliberate failure for rtk -q filter testing ==> expected: <1> but was: <2> + at org.junit.jupiter.api.AssertionFailureBuilder.build(AssertionFailureBuilder.java:151) + at org.junit.jupiter.api.AssertionFailureBuilder.buildAndThrow(AssertionFailureBuilder.java:132) + at org.junit.jupiter.api.AssertEquals.failNotEqual(AssertEquals.java:197) + at org.junit.jupiter.api.AssertEquals.assertEquals(AssertEquals.java:150) + at org.junit.jupiter.api.Assertions.assertEquals(Assertions.java:563) + at x.FailTest.this_will_fail(FailTest.java:9) + at java.base/java.lang.reflect.Method.invoke(Method.java:580) + at java.base/java.util.ArrayList.forEach(ArrayList.java:1596) + at java.base/java.util.ArrayList.forEach(ArrayList.java:1596) + +[ERROR] Failures: +[ERROR] FailTest.this_will_fail:9 deliberate failure for rtk -q filter testing ==> expected: <1> but was: <2> +[ERROR] Tests run: 6, Failures: 1, Errors: 0, Skipped: 0 +[ERROR] Failed to execute goal org.apache.maven.plugins:maven-surefire-plugin:3.5.5:test (default-test) on project mvnq: There are test failures. +[ERROR] +[ERROR] See /tmp/mvnq/target/surefire-reports for the individual test results. +[ERROR] See dump files (if any exist) [date].dump, [date]-jvmRun[N].dump and [date].dumpstream. +[ERROR] -> [Help 1] +[ERROR] +[ERROR] To see the full stack trace of the errors, re-run Maven with the -e switch. +[ERROR] Re-run Maven using the -X switch to enable full debug logging. +[ERROR] +[ERROR] For more information about the errors and possible solutions, please read the following articles: +[ERROR] [Help 1] http://cwiki.apache.org/confluence/display/MAVEN/MojoFailureException diff --git a/tests/fixtures/mvn_reactor_fail_slice_raw.txt b/tests/fixtures/mvn_reactor_fail_slice_raw.txt new file mode 100644 index 000000000..dae81e911 --- /dev/null +++ b/tests/fixtures/mvn_reactor_fail_slice_raw.txt @@ -0,0 +1,87 @@ +[INFO] Scanning for projects... +[INFO] ------------------------------------------------------------------------ +[INFO] Reactor Build Order: +[INFO] +[INFO] multi-module-skeleton [pom] +[INFO] child-a [jar] +[INFO] child-b [jar] +[INFO] +[INFO] ---------------< com.example.rtk:multi-module-skeleton >---------------- +[INFO] Building multi-module-skeleton 1.0.0-SNAPSHOT [1/3] +[INFO] from pom.xml +[INFO] --------------------------------[ pom ]--------------------------------- +[INFO] +[INFO] --- install:3.1.2:install (default-install) @ multi-module-skeleton --- +[INFO] Installing /tmp/rtk-reactor/pom.xml to /home/vcyber/.m2/repository/com/example/rtk/multi-module-skeleton/1.0.0-SNAPSHOT/multi-module-skeleton-1.0.0-SNAPSHOT.pom +[INFO] +[INFO] ----------------------< com.example.rtk:child-a >----------------------- +[INFO] Building child-a 1.0.0-SNAPSHOT [2/3] +[INFO] from child-a/pom.xml +[INFO] --------------------------------[ jar ]--------------------------------- +[INFO] +[INFO] --- resources:3.3.1:resources (default-resources) @ child-a --- +[INFO] skip non existing resourceDirectory /tmp/rtk-reactor/child-a/src/main/resources +[INFO] +[INFO] --- compiler:3.13.0:compile (default-compile) @ child-a --- +[INFO] Nothing to compile - all classes are up to date. +[INFO] +[INFO] --- resources:3.3.1:testResources (default-testResources) @ child-a --- +[INFO] skip non existing resourceDirectory /tmp/rtk-reactor/child-a/src/test/resources +[INFO] +[INFO] --- compiler:3.13.0:testCompile (default-testCompile) @ child-a --- +[INFO] No sources to compile +[INFO] +[INFO] --- surefire:2.17:test (default-test) @ child-a --- +[INFO] No tests to run. +[INFO] +[INFO] --- jar:3.1.2:jar (default-jar) @ child-a --- +[INFO] +[INFO] --- install:3.1.2:install (default-install) @ child-a --- +[INFO] Installing /tmp/rtk-reactor/child-a/pom.xml to /home/vcyber/.m2/repository/com/example/rtk/child-a/1.0.0-SNAPSHOT/child-a-1.0.0-SNAPSHOT.pom +[INFO] Installing /tmp/rtk-reactor/child-a/target/child-a-1.0.0-SNAPSHOT.jar to /home/vcyber/.m2/repository/com/example/rtk/child-a/1.0.0-SNAPSHOT/child-a-1.0.0-SNAPSHOT.jar +[INFO] +[INFO] ----------------------< com.example.rtk:child-b >----------------------- +[INFO] Building child-b 1.0.0-SNAPSHOT [3/3] +[INFO] from child-b/pom.xml +[INFO] --------------------------------[ jar ]--------------------------------- +[INFO] +[INFO] --- resources:3.3.1:resources (default-resources) @ child-b --- +[INFO] skip non existing resourceDirectory /tmp/rtk-reactor/child-b/src/main/resources +[INFO] +[INFO] --- compiler:3.13.0:compile (default-compile) @ child-b --- +[INFO] Recompiling the module because of changed source code. +[INFO] Compiling 1 source file with javac [debug target 21] to target/classes +[INFO] ------------------------------------------------------------- +[ERROR] COMPILATION ERROR : +[INFO] ------------------------------------------------------------- +[ERROR] /tmp/rtk-reactor/child-b/src/main/java/Empty.java:[1,22] cannot find symbol + symbol: class BogusType + location: class Empty +[INFO] 1 error +[INFO] ------------------------------------------------------------- +[INFO] ------------------------------------------------------------------------ +[INFO] Reactor Summary for multi-module-skeleton 1.0.0-SNAPSHOT: +[INFO] +[INFO] multi-module-skeleton .............................. SUCCESS [ 0.369 s] +[INFO] child-a ............................................ SUCCESS [ 1.657 s] +[INFO] child-b ............................................ FAILURE [ 0.940 s] +[INFO] ------------------------------------------------------------------------ +[INFO] BUILD FAILURE +[INFO] ------------------------------------------------------------------------ +[INFO] Total time: 3.193 s +[INFO] Finished at: 2026-05-25T12:11:41-03:00 +[INFO] ------------------------------------------------------------------------ +[ERROR] Failed to execute goal org.apache.maven.plugins:maven-compiler-plugin:3.13.0:compile (default-compile) on project child-b: Compilation failure +[ERROR] /tmp/rtk-reactor/child-b/src/main/java/Empty.java:[1,22] cannot find symbol +[ERROR] symbol: class BogusType +[ERROR] location: class Empty +[ERROR] -> [Help 1] +[ERROR] +[ERROR] To see the full stack trace of the errors, re-run Maven with the -e switch. +[ERROR] Re-run Maven using the -X switch to enable full debug logging. +[ERROR] +[ERROR] For more information about the errors and possible solutions, please read the following articles: +[ERROR] [Help 1] http://cwiki.apache.org/confluence/display/MAVEN/MojoFailureException +[ERROR] +[ERROR] After correcting the problems, you can resume the build with the command +[ERROR] mvn -rf :child-b diff --git a/tests/fixtures/mvn_reactor_pass_slice_raw.txt b/tests/fixtures/mvn_reactor_pass_slice_raw.txt new file mode 100644 index 000000000..54c01c9ca --- /dev/null +++ b/tests/fixtures/mvn_reactor_pass_slice_raw.txt @@ -0,0 +1,79 @@ +[INFO] Scanning for projects... +[INFO] ------------------------------------------------------------------------ +[INFO] Reactor Build Order: +[INFO] +[INFO] multi-module-skeleton [pom] +[INFO] child-a [jar] +[INFO] child-b [jar] +[INFO] +[INFO] ---------------< com.example.rtk:multi-module-skeleton >---------------- +[INFO] Building multi-module-skeleton 1.0.0-SNAPSHOT [1/3] +[INFO] from pom.xml +[INFO] --------------------------------[ pom ]--------------------------------- +[INFO] +[INFO] --- install:3.1.2:install (default-install) @ multi-module-skeleton --- +[INFO] Installing /tmp/rtk-reactor/pom.xml to /home/vcyber/.m2/repository/com/example/rtk/multi-module-skeleton/1.0.0-SNAPSHOT/multi-module-skeleton-1.0.0-SNAPSHOT.pom +[INFO] +[INFO] ----------------------< com.example.rtk:child-a >----------------------- +[INFO] Building child-a 1.0.0-SNAPSHOT [2/3] +[INFO] from child-a/pom.xml +[INFO] --------------------------------[ jar ]--------------------------------- +[INFO] +[INFO] --- resources:3.3.1:resources (default-resources) @ child-a --- +[INFO] skip non existing resourceDirectory /tmp/rtk-reactor/child-a/src/main/resources +[INFO] +[INFO] --- compiler:3.13.0:compile (default-compile) @ child-a --- +[INFO] Nothing to compile - all classes are up to date. +[INFO] +[INFO] --- resources:3.3.1:testResources (default-testResources) @ child-a --- +[INFO] skip non existing resourceDirectory /tmp/rtk-reactor/child-a/src/test/resources +[INFO] +[INFO] --- compiler:3.13.0:testCompile (default-testCompile) @ child-a --- +[INFO] No sources to compile +[INFO] +[INFO] --- surefire:2.17:test (default-test) @ child-a --- +[INFO] No tests to run. +[INFO] +[INFO] --- jar:3.1.2:jar (default-jar) @ child-a --- +[INFO] +[INFO] --- install:3.1.2:install (default-install) @ child-a --- +[INFO] Installing /tmp/rtk-reactor/child-a/pom.xml to /home/vcyber/.m2/repository/com/example/rtk/child-a/1.0.0-SNAPSHOT/child-a-1.0.0-SNAPSHOT.pom +[INFO] Installing /tmp/rtk-reactor/child-a/target/child-a-1.0.0-SNAPSHOT.jar to /home/vcyber/.m2/repository/com/example/rtk/child-a/1.0.0-SNAPSHOT/child-a-1.0.0-SNAPSHOT.jar +[INFO] +[INFO] ----------------------< com.example.rtk:child-b >----------------------- +[INFO] Building child-b 1.0.0-SNAPSHOT [3/3] +[INFO] from child-b/pom.xml +[INFO] --------------------------------[ jar ]--------------------------------- +[INFO] +[INFO] --- resources:3.3.1:resources (default-resources) @ child-b --- +[INFO] skip non existing resourceDirectory /tmp/rtk-reactor/child-b/src/main/resources +[INFO] +[INFO] --- compiler:3.13.0:compile (default-compile) @ child-b --- +[INFO] Nothing to compile - all classes are up to date. +[INFO] +[INFO] --- resources:3.3.1:testResources (default-testResources) @ child-b --- +[INFO] skip non existing resourceDirectory /tmp/rtk-reactor/child-b/src/test/resources +[INFO] +[INFO] --- compiler:3.13.0:testCompile (default-testCompile) @ child-b --- +[INFO] No sources to compile +[INFO] +[INFO] --- surefire:2.17:test (default-test) @ child-b --- +[INFO] No tests to run. +[INFO] +[INFO] --- jar:3.1.2:jar (default-jar) @ child-b --- +[INFO] +[INFO] --- install:3.1.2:install (default-install) @ child-b --- +[INFO] Installing /tmp/rtk-reactor/child-b/pom.xml to /home/vcyber/.m2/repository/com/example/rtk/child-b/1.0.0-SNAPSHOT/child-b-1.0.0-SNAPSHOT.pom +[INFO] Installing /tmp/rtk-reactor/child-b/target/child-b-1.0.0-SNAPSHOT.jar to /home/vcyber/.m2/repository/com/example/rtk/child-b/1.0.0-SNAPSHOT/child-b-1.0.0-SNAPSHOT.jar +[INFO] ------------------------------------------------------------------------ +[INFO] Reactor Summary for multi-module-skeleton 1.0.0-SNAPSHOT: +[INFO] +[INFO] multi-module-skeleton .............................. SUCCESS [ 0.353 s] +[INFO] child-a ............................................ SUCCESS [ 1.676 s] +[INFO] child-b ............................................ SUCCESS [ 0.069 s] +[INFO] ------------------------------------------------------------------------ +[INFO] BUILD SUCCESS +[INFO] ------------------------------------------------------------------------ +[INFO] Total time: 2.284 s +[INFO] Finished at: 2026-05-25T12:11:31-03:00 +[INFO] ------------------------------------------------------------------------ diff --git a/tests/fixtures/mvn_test_compile_fail_slice_raw.txt b/tests/fixtures/mvn_test_compile_fail_slice_raw.txt new file mode 100644 index 000000000..ac5c7e051 --- /dev/null +++ b/tests/fixtures/mvn_test_compile_fail_slice_raw.txt @@ -0,0 +1,29 @@ +[INFO] Scanning for projects... +[INFO] +[INFO] ----------------------< commons-cli:commons-cli >----------------------- +[INFO] Building Apache Commons CLI 1.11.1-SNAPSHOT +[INFO] from pom.xml +[INFO] --------------------------------[ jar ]--------------------------------- +[INFO] +[INFO] --- compiler:3.15.0:compile (default-compile) @ commons-cli --- +[INFO] Recompiling the module because of changed source code. +[INFO] Compiling 37 source files with javac [debug release 8] to target/classes +[INFO] ------------------------------------------------------------- +[ERROR] COMPILATION ERROR : +[INFO] ------------------------------------------------------------- +[ERROR] src/main/java/org/apache/commons/cli/CompileBreaker.java:[21,16] cannot find symbol + symbol: variable bar + location: class org.apache.commons.cli.CompileBreaker +[INFO] 1 error +[INFO] ------------------------------------------------------------- +[INFO] ------------------------------------------------------------------------ +[INFO] BUILD FAILURE +[INFO] ------------------------------------------------------------------------ +[INFO] Total time: 3.214 s +[INFO] Finished at: 2026-05-21T15:02:31Z +[INFO] ------------------------------------------------------------------------ +[ERROR] Failed to execute goal org.apache.maven.plugins:maven-compiler-plugin:3.15.0:compile (default-compile) on project commons-cli: Compilation failure +[ERROR] /home/runner/commons-cli/src/main/java/org/apache/commons/cli/CompileBreaker.java:[21,16] cannot find symbol +[ERROR] symbol: variable bar +[ERROR] location: class org.apache.commons.cli.CompileBreaker +[ERROR] -> [Help 1] diff --git a/tests/fixtures/mvn_test_fail_slice_raw.txt b/tests/fixtures/mvn_test_fail_slice_raw.txt new file mode 100644 index 000000000..4af9af21a --- /dev/null +++ b/tests/fixtures/mvn_test_fail_slice_raw.txt @@ -0,0 +1,46 @@ +[INFO] Scanning for projects... +[INFO] +[INFO] ----------------------< commons-cli:commons-cli >----------------------- +[INFO] Building Apache Commons CLI 1.11.1-SNAPSHOT +[INFO] from pom.xml +[INFO] --------------------------------[ jar ]--------------------------------- +[INFO] +[INFO] --- surefire:3.5.5:test (default-test) @ commons-cli --- +[INFO] +[INFO] ------------------------------------------------------- +[INFO] T E S T S +[INFO] ------------------------------------------------------- +[INFO] Running org.apache.commons.cli.SolrCliTest +[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.005 s -- in org.apache.commons.cli.SolrCliTest +[INFO] Running org.apache.commons.cli.RtkInducedFailTest +[ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.033 s <<< FAILURE! -- in org.apache.commons.cli.RtkInducedFailTest +[ERROR] org.apache.commons.cli.RtkInducedFailTest.rtkInducedFailure -- Time elapsed: 0.025 s <<< FAILURE! +org.opentest4j.AssertionFailedError: expected: but was: + at org.junit.jupiter.api.AssertionFailureBuilder.build(AssertionFailureBuilder.java:151) + at org.junit.jupiter.api.AssertionFailureBuilder.buildAndThrow(AssertionFailureBuilder.java:132) + at org.junit.jupiter.api.AssertEquals.failNotEqual(AssertEquals.java:197) + at org.junit.jupiter.api.AssertEquals.assertEquals(AssertEquals.java:182) + at org.junit.jupiter.api.AssertEquals.assertEquals(AssertEquals.java:177) + at org.junit.jupiter.api.Assertions.assertEquals(Assertions.java:1145) + at org.apache.commons.cli.RtkInducedFailTest.rtkInducedFailure(RtkInducedFailTest.java:25) + at java.base/java.lang.reflect.Method.invoke(Method.java:580) + at java.base/java.util.ArrayList.forEach(ArrayList.java:1596) + at java.base/java.util.ArrayList.forEach(ArrayList.java:1596) + +[INFO] Running org.apache.commons.cli.PatternOptionBuilderTest +[INFO] Tests run: 10, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.066 s -- in org.apache.commons.cli.PatternOptionBuilderTest +[INFO] +[INFO] Results: +[INFO] +[ERROR] Failures: +[ERROR] RtkInducedFailTest.rtkInducedFailure:25 expected: but was: +[INFO] +[ERROR] Tests run: 978, Failures: 1, Errors: 0, Skipped: 61 +[INFO] +[INFO] ------------------------------------------------------------------------ +[INFO] BUILD FAILURE +[INFO] ------------------------------------------------------------------------ +[INFO] Total time: 01:05 min +[INFO] Finished at: 2026-05-21T14:57:09Z +[INFO] ------------------------------------------------------------------------ +[ERROR] Failed to execute goal org.apache.maven.plugins:maven-surefire-plugin:3.5.5:test (default-test) on project commons-cli: There are test failures. diff --git a/tests/fixtures/mvn_test_multifail_slice_raw.txt b/tests/fixtures/mvn_test_multifail_slice_raw.txt new file mode 100644 index 000000000..ade0090be --- /dev/null +++ b/tests/fixtures/mvn_test_multifail_slice_raw.txt @@ -0,0 +1,70 @@ +[INFO] Scanning for projects... +[INFO] +[INFO] -----------------< com.example.rtk:multifail-skeleton >----------------- +[INFO] Building multifail-skeleton 1.0.0-SNAPSHOT +[INFO] from pom.xml +[INFO] --------------------------------[ jar ]--------------------------------- +[INFO] +[INFO] --- surefire:3.5.5:test (default-test) @ multifail-skeleton --- +[INFO] +[INFO] ------------------------------------------------------- +[INFO] T E S T S +[INFO] ------------------------------------------------------- +[INFO] Running com.example.rtk.CalcTest +[ERROR] Tests run: 3, Failures: 1, Errors: 1, Skipped: 0, Time elapsed: 0.108 s <<< FAILURE! -- in com.example.rtk.CalcTest +[ERROR] com.example.rtk.CalcTest.failOne -- Time elapsed: 0.050 s <<< FAILURE! +org.opentest4j.AssertionFailedError: failOne: addition should equal five ==> expected: <5> but was: <4> + at org.junit.jupiter.api.AssertionFailureBuilder.build(AssertionFailureBuilder.java:151) + at org.junit.jupiter.api.AssertionFailureBuilder.buildAndThrow(AssertionFailureBuilder.java:132) + at org.junit.jupiter.api.AssertEquals.failNotEqual(AssertEquals.java:197) + at org.junit.jupiter.api.AssertEquals.assertEquals(AssertEquals.java:150) + at org.junit.jupiter.api.Assertions.assertEquals(Assertions.java:563) + at com.example.rtk.CalcTest.failOne(CalcTest.java:12) + at java.base/java.lang.reflect.Method.invoke(Method.java:580) + at java.base/java.util.ArrayList.forEach(ArrayList.java:1596) + at java.base/java.util.ArrayList.forEach(ArrayList.java:1596) + +[ERROR] com.example.rtk.CalcTest.failTwo -- Time elapsed: 0.006 s <<< ERROR! +java.lang.IllegalStateException: failTwo: induced error for fixture capture + at com.example.rtk.CalcTest.failTwo(CalcTest.java:17) + at java.base/java.lang.reflect.Method.invoke(Method.java:580) + at java.base/java.util.ArrayList.forEach(ArrayList.java:1596) + at java.base/java.util.ArrayList.forEach(ArrayList.java:1596) + +[INFO] Running com.example.rtk.BoomTest +[ERROR] Tests run: 1, Failures: 0, Errors: 1, Skipped: 0, Time elapsed: 0.006 s <<< FAILURE! -- in com.example.rtk.BoomTest +[ERROR] com.example.rtk.BoomTest.boom -- Time elapsed: 0.004 s <<< ERROR! +java.lang.IllegalStateException: boom: errors-only class for fixture capture + at com.example.rtk.BoomTest.boom(BoomTest.java:8) + at java.base/java.lang.reflect.Method.invoke(Method.java:580) + at java.base/java.util.ArrayList.forEach(ArrayList.java:1596) + at java.base/java.util.ArrayList.forEach(ArrayList.java:1596) + +[INFO] +[INFO] Results: +[INFO] +[ERROR] Failures: +[ERROR] CalcTest.failOne:12 failOne: addition should equal five ==> expected: <5> but was: <4> +[ERROR] Errors: +[ERROR] BoomTest.boom:8 IllegalState boom: errors-only class for fixture capture +[ERROR] CalcTest.failTwo:17 IllegalState failTwo: induced error for fixture capture +[INFO] +[ERROR] Tests run: 4, Failures: 1, Errors: 2, Skipped: 0 +[INFO] +[INFO] ------------------------------------------------------------------------ +[INFO] BUILD FAILURE +[INFO] ------------------------------------------------------------------------ +[INFO] Total time: 3.171 s +[INFO] Finished at: 2026-06-06T10:53:18-03:00 +[INFO] ------------------------------------------------------------------------ +[ERROR] Failed to execute goal org.apache.maven.plugins:maven-surefire-plugin:3.5.5:test (default-test) on project multifail-skeleton: There are test failures. +[ERROR] +[ERROR] See /home/vcyber/projetos/rtk/tests/fixtures/multifail-skeleton/target/surefire-reports for the individual test results. +[ERROR] See dump files (if any exist) [date].dump, [date]-jvmRun[N].dump and [date].dumpstream. +[ERROR] -> [Help 1] +[ERROR] +[ERROR] To see the full stack trace of the errors, re-run Maven with the -e switch. +[ERROR] Re-run Maven using the -X switch to enable full debug logging. +[ERROR] +[ERROR] For more information about the errors and possible solutions, please read the following articles: +[ERROR] [Help 1] http://cwiki.apache.org/confluence/display/MAVEN/MojoFailureException diff --git a/tests/fixtures/mvn_test_pass_full_raw.txt.gz b/tests/fixtures/mvn_test_pass_full_raw.txt.gz new file mode 100644 index 000000000..c44504db9 Binary files /dev/null and b/tests/fixtures/mvn_test_pass_full_raw.txt.gz differ diff --git a/tests/fixtures/mvn_test_pass_slice_raw.txt b/tests/fixtures/mvn_test_pass_slice_raw.txt new file mode 100644 index 000000000..99c782416 --- /dev/null +++ b/tests/fixtures/mvn_test_pass_slice_raw.txt @@ -0,0 +1,33 @@ +[INFO] Scanning for projects... +[INFO] +[INFO] ----------------------< commons-cli:commons-cli >----------------------- +[INFO] Building Apache Commons CLI 1.11.1-SNAPSHOT +[INFO] from pom.xml +[INFO] --------------------------------[ jar ]--------------------------------- +[INFO] +[INFO] --- surefire:3.5.5:test (default-test) @ commons-cli --- +[INFO] +[INFO] ------------------------------------------------------- +[INFO] T E S T S +[INFO] ------------------------------------------------------- +[INFO] Running org.apache.commons.cli.help.UtilTest +[INFO] Tests run: 34, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.023 s -- in org.apache.commons.cli.help.UtilTest +[INFO] Running org.apache.commons.cli.help.TextStyleTest +[INFO] Tests run: 13, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.240 s -- in org.apache.commons.cli.help.TextStyleTest +[INFO] Running org.apache.commons.cli.DefaultParserTest +[WARNING] Tests run: 87, Failures: 0, Errors: 0, Skipped: 2, Time elapsed: 1.023 s -- in org.apache.commons.cli.DefaultParserTest +[INFO] Running org.apache.commons.cli.ConverterTests +[INFO] Tests run: 14, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.204 s -- in org.apache.commons.cli.ConverterTests +[INFO] Running org.apache.commons.cli.AlreadySelectedExceptionTest +[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.014 s -- in org.apache.commons.cli.AlreadySelectedExceptionTest +[INFO] +[INFO] Results: +[INFO] +[INFO] Tests run: 977, Failures: 0, Errors: 0, Skipped: 61 +[INFO] +[INFO] ------------------------------------------------------------------------ +[INFO] BUILD SUCCESS +[INFO] ------------------------------------------------------------------------ +[INFO] Total time: 01:14 min +[INFO] Finished at: 2026-05-21T14:54:30Z +[INFO] ------------------------------------------------------------------------