diff --git a/src/gradle_cmd.rs b/src/gradle_cmd.rs new file mode 100644 index 00000000..51233b53 --- /dev/null +++ b/src/gradle_cmd.rs @@ -0,0 +1,961 @@ +use crate::tracking; +use crate::utils::truncate; +use anyhow::{Context, Result}; +use std::collections::HashMap; +use std::ffi::OsString; +use std::path::Path; +use std::process::Command; + +/// Detect whether to use ./gradlew (wrapper) or gradle +fn gradle_executable() -> &'static str { + if Path::new("./gradlew").exists() { + "./gradlew" + } else { + "gradle" + } +} + +/// Generic gradle command runner with filtering +fn run_gradle_filtered( + subcommand: &str, + args: &[String], + verbose: u8, + filter_fn: F, +) -> Result<()> +where + F: Fn(&str) -> String, +{ + let timer = tracking::TimedExecution::start(); + let gradle = gradle_executable(); + + let mut cmd = Command::new(gradle); + cmd.arg(subcommand); + for arg in args { + cmd.arg(arg); + } + + if verbose > 0 { + eprintln!("Running: {} {} {}", gradle, subcommand, args.join(" ")); + } + + let output = cmd.output().with_context(|| { + format!( + "Failed to run {} {}. Is Gradle installed or is ./gradlew available?", + gradle, subcommand + ) + })?; + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let raw = format!("{}\n{}", stdout, stderr); + + let exit_code = output + .status + .code() + .unwrap_or(if output.status.success() { 0 } else { 1 }); + let filtered = filter_fn(&raw); + + if let Some(hint) = crate::tee::tee_and_hint(&raw, &format!("gradle_{}", subcommand), exit_code) + { + println!("{}\n{}", filtered, hint); + } else { + println!("{}", filtered); + } + + timer.track( + &format!("gradle {} {}", subcommand, args.join(" ")), + &format!("rtk gradle {} {}", subcommand, args.join(" ")), + &raw, + &filtered, + ); + + if !output.status.success() { + std::process::exit(exit_code); + } + + Ok(()) +} + +pub fn run_build(args: &[String], verbose: u8) -> Result<()> { + run_gradle_filtered("build", args, verbose, filter_gradle_build) +} + +pub fn run_test(args: &[String], verbose: u8) -> Result<()> { + run_gradle_filtered("test", args, verbose, filter_gradle_test) +} + +pub fn run_clean(args: &[String], verbose: u8) -> Result<()> { + run_gradle_filtered("clean", args, verbose, filter_gradle_build) +} + +pub fn run_assemble(args: &[String], verbose: u8) -> Result<()> { + run_gradle_filtered("assemble", args, verbose, filter_gradle_build) +} + +pub fn run_dependencies(args: &[String], verbose: u8) -> Result<()> { + run_gradle_filtered("dependencies", args, verbose, filter_gradle_dependencies) +} + +pub fn run_other(args: &[OsString], verbose: u8) -> Result<()> { + if args.is_empty() { + anyhow::bail!("gradle: no subcommand specified"); + } + + let timer = tracking::TimedExecution::start(); + let gradle = gradle_executable(); + let subcommand = args[0].to_string_lossy(); + + let mut cmd = Command::new(gradle); + cmd.arg(&*subcommand); + for arg in &args[1..] { + cmd.arg(arg); + } + + if verbose > 0 { + eprintln!("Running: {} {} ...", gradle, subcommand); + } + + let output = cmd + .output() + .with_context(|| format!("Failed to run {} {}", gradle, subcommand))?; + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let raw = format!("{}\n{}", stdout, stderr); + + print!("{}", stdout); + eprint!("{}", stderr); + + timer.track( + &format!("gradle {}", subcommand), + &format!("rtk gradle {}", subcommand), + &raw, + &raw, + ); + + if !output.status.success() { + std::process::exit(output.status.code().unwrap_or(1)); + } + + Ok(()) +} + +/// Filter Gradle build/assemble/clean output. +/// Strips download progress, configuration lines, and task noise. +/// Keeps: errors, warnings, BUILD result, and actionable output. +fn filter_gradle_build(output: &str) -> String { + let mut errors: Vec = Vec::new(); + let mut warnings: Vec = Vec::new(); + let mut error_count = 0; + let mut warning_count = 0; + let mut in_error = false; + let mut current_error: Vec = Vec::new(); + let mut build_result = String::new(); + let mut task_count = 0; + let mut actionable_tasks = 0; + let mut up_to_date = 0; + let mut from_cache = 0; + + for line in output.lines() { + let trimmed = line.trim(); + + // Skip noise: downloads, progress indicators, empty lines + if trimmed.is_empty() + || trimmed.starts_with("Downloading") + || trimmed.starts_with("Download ") + || trimmed.starts_with("Starting a Gradle") + || trimmed.starts_with("Welcome to Gradle") + || trimmed.starts_with("To honour the JVM settings") + || trimmed.starts_with("Daemon will be") + || trimmed.starts_with("Configuration on demand") + || trimmed.starts_with("Calculating task graph") + || trimmed.starts_with("Type-safe ") + || trimmed.starts_with("See https://") + || trimmed.starts_with("https://") + { + continue; + } + + // Track task execution for summary + if trimmed.starts_with("> Task :") { + task_count += 1; + if trimmed.contains("UP-TO-DATE") { + up_to_date += 1; + } else if trimmed.contains("FROM-CACHE") { + from_cache += 1; + } else if !trimmed.contains("SKIPPED") && !trimmed.contains("NO-SOURCE") { + actionable_tasks += 1; + } + continue; + } + + // Capture BUILD SUCCESSFUL/FAILED line + if trimmed.starts_with("BUILD SUCCESSFUL") || trimmed.starts_with("BUILD FAILED") { + build_result = trimmed.to_string(); + continue; + } + + // Skip timing lines like "5 actionable tasks: 3 executed, 2 up-to-date" + if trimmed.contains("actionable task") { + continue; + } + + // Detect error blocks + if trimmed.starts_with("e:") || trimmed.starts_with("FAILURE:") { + if in_error && !current_error.is_empty() { + errors.push(current_error.join("\n")); + current_error.clear(); + } + error_count += 1; + in_error = true; + current_error.push(trimmed.to_string()); + continue; + } + + // Detect "* What went wrong:" blocks + if trimmed == "* What went wrong:" { + if in_error && !current_error.is_empty() { + errors.push(current_error.join("\n")); + current_error.clear(); + } + in_error = true; + continue; + } + + // End of error context + if in_error && (trimmed.starts_with("* Try:") || trimmed.starts_with("* Get more help")) { + if !current_error.is_empty() { + errors.push(current_error.join("\n")); + current_error.clear(); + } + in_error = false; + continue; + } + + // Collect error continuation lines + if in_error { + current_error.push(trimmed.to_string()); + continue; + } + + // Collect warnings + if trimmed.starts_with("w:") || trimmed.starts_with("warning:") { + warning_count += 1; + warnings.push(trimmed.to_string()); + continue; + } + } + + // Flush any remaining error + if !current_error.is_empty() { + errors.push(current_error.join("\n")); + } + + // Build output + let mut result = String::new(); + + // Success case + if build_result.contains("SUCCESSFUL") && error_count == 0 { + result.push_str(&format!("✓ Gradle: {}", build_result)); + if task_count > 0 { + let mut parts: Vec = Vec::new(); + if actionable_tasks > 0 { + parts.push(format!("{} executed", actionable_tasks)); + } + if up_to_date > 0 { + parts.push(format!("{} up-to-date", up_to_date)); + } + if from_cache > 0 { + parts.push(format!("{} cached", from_cache)); + } + if !parts.is_empty() { + result.push_str(&format!(" ({})", parts.join(", "))); + } + } + + if warning_count > 0 { + result.push_str(&format!( + "\n{} warning{}", + warning_count, + if warning_count > 1 { "s" } else { "" } + )); + for w in warnings.iter().take(5) { + result.push_str(&format!("\n {}", truncate(w, 120))); + } + if warnings.len() > 5 { + result.push_str(&format!("\n ... +{} more", warnings.len() - 5)); + } + } + + return result; + } + + // Failure case + if error_count > 0 || build_result.contains("FAILED") { + result.push_str(&format!( + "Gradle build: {} error{}", + error_count, + if error_count > 1 { "s" } else { "" } + )); + if warning_count > 0 { + result.push_str(&format!(", {} warnings", warning_count)); + } + result.push('\n'); + result.push_str("═══════════════════════════════════════\n"); + + for (i, error) in errors.iter().take(10).enumerate() { + result.push_str(&format!("{}. {}\n", i + 1, truncate(error, 200))); + } + if errors.len() > 10 { + result.push_str(&format!("\n... +{} more errors\n", errors.len() - 10)); + } + + return result.trim().to_string(); + } + + // No build result found — probably non-build task or empty output + if output.trim().is_empty() { + return "✓ Gradle: Complete".to_string(); + } + + // Fallback: return abbreviated output + let lines: Vec<&str> = output.lines().collect(); + if lines.len() <= 5 { + return output.trim().to_string(); + } + + result.push_str(&format!("Gradle: {} lines of output\n", lines.len())); + for line in lines.iter().take(5) { + result.push_str(&format!(" {}\n", truncate(line, 120))); + } + result.push_str(&format!(" ... +{} more lines", lines.len() - 5)); + result.trim().to_string() +} + +/// Filter Gradle test output. +/// Strips per-test progress, keeps only failures and summary. +fn filter_gradle_test(output: &str) -> String { + let mut total_tests = 0; + let mut failures = 0; + let mut skipped = 0; + let mut failed_tests: Vec<(String, Vec)> = Vec::new(); + let mut current_failure: Option<(String, Vec)> = None; + let mut in_failure = false; + let mut build_result = String::new(); + let mut suites_run: HashMap = HashMap::new(); // suite -> (pass, fail, skip) + + for line in output.lines() { + let trimmed = line.trim(); + + // Skip noise + if trimmed.is_empty() + || trimmed.starts_with("Downloading") + || trimmed.starts_with("Download ") + || trimmed.starts_with("Starting a Gradle") + || trimmed.starts_with("> Task :") + || trimmed.starts_with("Calculating") + || trimmed.starts_with("Type-safe") + || trimmed.starts_with("See https://") + || trimmed.starts_with("https://") + || trimmed.contains("actionable task") + { + continue; + } + + // Capture BUILD result + if trimmed.starts_with("BUILD SUCCESSFUL") || trimmed.starts_with("BUILD FAILED") { + build_result = trimmed.to_string(); + continue; + } + + // Parse test result summary lines like: + // "com.example.MyTest > myTestMethod PASSED" + // "com.example.MyTest > myTestMethod FAILED" + if let Some(status) = extract_test_result(trimmed) { + total_tests += 1; + + // Get suite name (class name before " > ") + let suite = trimmed.split(" > ").next().unwrap_or("unknown").to_string(); + let entry = suites_run.entry(suite).or_insert((0, 0, 0)); + + match status { + TestStatus::Passed => { + entry.0 += 1; + // Flush any in-progress failure + if let Some(f) = current_failure.take() { + failed_tests.push(f); + } + in_failure = false; + } + TestStatus::Failed => { + failures += 1; + entry.1 += 1; + // Flush any in-progress failure + if let Some(f) = current_failure.take() { + failed_tests.push(f); + } + let test_name = trimmed + .rsplit(" FAILED") + .nth(1) + .unwrap_or(trimmed) + .to_string(); + current_failure = Some((test_name, Vec::new())); + in_failure = true; + } + TestStatus::Skipped => { + skipped += 1; + entry.2 += 1; + // Flush any in-progress failure + if let Some(f) = current_failure.take() { + failed_tests.push(f); + } + in_failure = false; + } + } + continue; + } + + // Parse Gradle test report summary like: + // "100 tests completed, 2 failed, 3 skipped" + if trimmed.contains("tests completed") { + if let Some((t, f, s)) = parse_test_summary_line(trimmed) { + // Use report numbers if we didn't individually track + if total_tests == 0 { + total_tests = t; + failures = f; + skipped = s; + } + } + continue; + } + + // Collect failure output + if in_failure { + if let Some(ref mut f) = current_failure { + f.1.push(trimmed.to_string()); + } + } + } + + // Flush last failure + if let Some(f) = current_failure.take() { + failed_tests.push(f); + } + + let passed = if total_tests >= failures + skipped { + total_tests - failures - skipped + } else { + 0 + }; + + // No tests found + if total_tests == 0 && failures == 0 { + if build_result.contains("SUCCESSFUL") { + return "✓ Gradle test: No tests found (BUILD SUCCESSFUL)".to_string(); + } + if build_result.contains("FAILED") { + // Build failure, use build filter + return filter_gradle_build(output); + } + return "Gradle test: No test results found".to_string(); + } + + // All passed + if failures == 0 { + let mut result = format!("✓ Gradle test: {} passed", passed); + if skipped > 0 { + result.push_str(&format!(", {} skipped", skipped)); + } + if suites_run.len() > 1 { + result.push_str(&format!(" in {} suites", suites_run.len())); + } + return result; + } + + // Has failures + let mut result = String::new(); + result.push_str(&format!( + "Gradle test: {} passed, {} failed", + passed, failures + )); + if skipped > 0 { + result.push_str(&format!(", {} skipped", skipped)); + } + result.push('\n'); + result.push_str("═══════════════════════════════════════\n"); + + for (test_name, output_lines) in failed_tests.iter().take(10) { + result.push_str(&format!(" ❌ {}\n", test_name)); + + // Show relevant failure lines (assertions, expected/actual, exceptions) + let relevant: Vec<&String> = output_lines + .iter() + .filter(|l| { + let lower = l.to_lowercase(); + !l.trim().is_empty() + && (lower.contains("expected") + || lower.contains("actual") + || lower.contains("assert") + || lower.contains("error") + || lower.contains("exception") + || lower.contains("but was") + || lower.contains("at ")) + }) + .take(5) + .collect(); + + for line in relevant { + result.push_str(&format!(" {}\n", truncate(line, 100))); + } + } + + if failed_tests.len() > 10 { + result.push_str(&format!( + "\n... +{} more failed tests\n", + failed_tests.len() - 10 + )); + } + + result.trim().to_string() +} + +/// Filter Gradle dependencies output. +/// Compacts the dependency tree, removing duplicate configurations. +fn filter_gradle_dependencies(output: &str) -> String { + let mut configs: HashMap> = HashMap::new(); + let mut current_config = String::new(); + let mut current_deps: Vec = Vec::new(); + let mut total_deps = 0; + + for line in output.lines() { + let trimmed = line.trim(); + + // Skip noise + if trimmed.is_empty() + || trimmed.starts_with("Downloading") + || trimmed.starts_with("Download ") + || trimmed.starts_with("> Task") + || trimmed.starts_with("BUILD") + || trimmed.starts_with("Starting a Gradle") + || trimmed.contains("actionable task") + { + continue; + } + + // Detect configuration header like "compileClasspath - Compile classpath" + if !trimmed.starts_with('+') + && !trimmed.starts_with('|') + && !trimmed.starts_with('\\') + && !trimmed.starts_with(' ') + && trimmed.contains(" - ") + { + // Save previous config + if !current_config.is_empty() && !current_deps.is_empty() { + configs.insert(current_config.clone(), current_deps.clone()); + } + current_config = trimmed.split(" - ").next().unwrap_or(trimmed).to_string(); + current_deps = Vec::new(); + continue; + } + + // Detect "No dependencies" marker + if trimmed == "No dependencies" || trimmed == "(n)" { + continue; + } + + // Collect top-level dependencies (first level of tree) + if (trimmed.starts_with("+---") || trimmed.starts_with("\\---")) + && !current_config.is_empty() + { + let dep = trimmed + .trim_start_matches("+--- ") + .trim_start_matches("\\--- ") + .to_string(); + current_deps.push(dep); + total_deps += 1; + } + } + + // Save last config + if !current_config.is_empty() && !current_deps.is_empty() { + configs.insert(current_config, current_deps); + } + + if configs.is_empty() { + return "Gradle dependencies: No dependencies found".to_string(); + } + + // Build compact output: only show configs with deps, max 20 deps each + let mut result = String::new(); + result.push_str(&format!( + "Gradle dependencies: {} top-level across {} configurations\n", + total_deps, + configs.len() + )); + result.push_str("═══════════════════════════════════════\n"); + + // Sort configs for deterministic output + let mut sorted_configs: Vec<_> = configs.into_iter().collect(); + sorted_configs.sort_by(|a, b| a.0.cmp(&b.0)); + + for (config, deps) in &sorted_configs { + result.push_str(&format!("\n{} ({}):\n", config, deps.len())); + for dep in deps.iter().take(20) { + result.push_str(&format!(" {}\n", truncate(dep, 100))); + } + if deps.len() > 20 { + result.push_str(&format!(" ... +{} more\n", deps.len() - 20)); + } + } + + result.trim().to_string() +} + +#[derive(Debug, PartialEq)] +enum TestStatus { + Passed, + Failed, + Skipped, +} + +/// Extract test result status from a Gradle test output line. +/// Lines look like: "com.example.MyTest > myTestMethod PASSED" +fn extract_test_result(line: &str) -> Option { + let trimmed = line.trim(); + if !trimmed.contains(" > ") { + return None; + } + + if trimmed.ends_with(" PASSED") { + Some(TestStatus::Passed) + } else if trimmed.ends_with(" FAILED") { + Some(TestStatus::Failed) + } else if trimmed.ends_with(" SKIPPED") { + Some(TestStatus::Skipped) + } else { + None + } +} + +/// Parse a Gradle test summary line like "100 tests completed, 2 failed, 3 skipped" +fn parse_test_summary_line(line: &str) -> Option<(usize, usize, usize)> { + let total = line + .split("tests completed") + .next()? + .trim() + .rsplit(' ') + .next()? + .parse::() + .ok()?; + + let failed = if line.contains("failed") { + line.split("failed") + .next()? + .trim() + .rsplit(|c: char| !c.is_ascii_digit()) + .next()? + .parse::() + .unwrap_or(0) + } else { + 0 + }; + + let skipped = if line.contains("skipped") { + line.split("skipped") + .next()? + .trim() + .rsplit(|c: char| !c.is_ascii_digit()) + .next()? + .parse::() + .unwrap_or(0) + } else { + 0 + }; + + Some((total, failed, skipped)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn count_tokens(text: &str) -> usize { + text.split_whitespace().count() + } + + #[test] + fn test_filter_gradle_build_success() { + let output = r#"Starting a Gradle Daemon (subsequent builds will be faster) + +> Task :compileJava UP-TO-DATE +> Task :processResources NO-SOURCE +> Task :classes UP-TO-DATE +> Task :jar UP-TO-DATE +> Task :assemble UP-TO-DATE +> Task :compileTestJava UP-TO-DATE +> Task :processTestResources NO-SOURCE +> Task :testClasses UP-TO-DATE +> Task :test UP-TO-DATE +> Task :check UP-TO-DATE +> Task :build UP-TO-DATE + +BUILD SUCCESSFUL in 2s +7 actionable tasks: 7 up-to-date"#; + + let result = filter_gradle_build(output); + assert!(result.contains("✓ Gradle")); + assert!(result.contains("SUCCESSFUL")); + assert!(result.contains("up-to-date")); + + let savings = 100.0 - (count_tokens(&result) as f64 / count_tokens(output) as f64 * 100.0); + assert!( + savings >= 60.0, + "Expected ≥60% savings, got {:.1}%", + savings + ); + } + + #[test] + fn test_filter_gradle_build_with_errors() { + let output = r#"> Task :compileJava FAILED +e: src/main/java/com/example/App.java:10:5: error: cannot find symbol +e: src/main/java/com/example/App.java:15:2: error: incompatible types + +FAILURE: Build failed with an exception. + +* What went wrong: +Execution failed for task ':compileJava'. +> Compilation failed; see the compiler error output for details. + +* Try: +> Run with --stacktrace option to get the stack trace. + +BUILD FAILED in 3s +1 actionable task: 1 executed"#; + + let result = filter_gradle_build(output); + assert!(result.contains("error")); + assert!(!result.contains("Task :compileJava")); + assert!(!result.contains("--stacktrace")); + } + + #[test] + fn test_filter_gradle_build_empty() { + let result = filter_gradle_build(""); + assert!(result.contains("✓ Gradle")); + } + + #[test] + fn test_filter_gradle_test_all_pass() { + let output = r#"> Task :compileJava UP-TO-DATE +> Task :processResources NO-SOURCE +> Task :classes UP-TO-DATE +> Task :compileTestJava UP-TO-DATE +> Task :processTestResources NO-SOURCE +> Task :testClasses UP-TO-DATE +> Task :test + +com.example.AppTest > testAddition PASSED +com.example.AppTest > testSubtraction PASSED +com.example.AppTest > testMultiplication PASSED +com.example.UtilsTest > testStringFormat PASSED +com.example.UtilsTest > testParsing PASSED + +BUILD SUCCESSFUL in 5s +3 actionable tasks: 1 executed, 2 up-to-date"#; + + let result = filter_gradle_test(output); + assert!(result.contains("✓ Gradle test")); + assert!(result.contains("5 passed")); + assert!(result.contains("2 suites")); + + let savings = 100.0 - (count_tokens(&result) as f64 / count_tokens(output) as f64 * 100.0); + assert!( + savings >= 60.0, + "Expected ≥60% savings, got {:.1}%", + savings + ); + } + + #[test] + fn test_filter_gradle_test_with_failures() { + let output = r#"> Task :test + +com.example.AppTest > testAddition PASSED +com.example.AppTest > testBroken FAILED + + org.opentest4j.AssertionFailedError: expected: <5> but was: <3> + at org.junit.jupiter.api.AssertEquals.assertEquals(AssertEquals.java:166) + at com.example.AppTest.testBroken(AppTest.java:25) + +com.example.AppTest > testAnother PASSED + +3 tests completed, 1 failed + +BUILD FAILED in 4s +3 actionable tasks: 1 executed, 2 up-to-date"#; + + let result = filter_gradle_test(output); + assert!(result.contains("2 passed")); + assert!(result.contains("1 failed")); + assert!(result.contains("testBroken")); + assert!(result.contains("expected: <5> but was: <3>")); + } + + #[test] + fn test_filter_gradle_test_with_skipped() { + let output = r#"> Task :test + +com.example.AppTest > testOk PASSED +com.example.AppTest > testDisabled SKIPPED +com.example.AppTest > testOther PASSED + +BUILD SUCCESSFUL in 2s"#; + + let result = filter_gradle_test(output); + assert!(result.contains("✓ Gradle test")); + assert!(result.contains("2 passed")); + assert!(result.contains("1 skipped")); + } + + #[test] + fn test_filter_gradle_test_no_tests() { + let output = r#"> Task :compileJava UP-TO-DATE +> Task :test NO-SOURCE + +BUILD SUCCESSFUL in 1s"#; + + let result = filter_gradle_test(output); + assert!(result.contains("No tests found")); + } + + #[test] + fn test_filter_gradle_dependencies() { + let output = r#"> Task :dependencies + +------------------------------------------------------------ +Root project 'myapp' +------------------------------------------------------------ + +compileClasspath - Compile classpath for source set 'main'. ++--- org.springframework.boot:spring-boot-starter-web:3.2.0 ++--- com.google.guava:guava:32.1.3-jre +\--- org.projectlombok:lombok:1.18.30 + +runtimeClasspath - Runtime classpath of source set 'main'. ++--- org.springframework.boot:spring-boot-starter-web:3.2.0 ++--- com.google.guava:guava:32.1.3-jre ++--- org.projectlombok:lombok:1.18.30 +\--- com.h2database:h2:2.2.224 + +testCompileClasspath - Compile classpath for source set 'test'. ++--- org.springframework.boot:spring-boot-starter-test:3.2.0 +\--- org.junit.jupiter:junit-jupiter:5.10.1 + +BUILD SUCCESSFUL in 1s +1 actionable task: 1 executed"#; + + let result = filter_gradle_dependencies(output); + assert!(result.contains("Gradle dependencies:")); + assert!(result.contains("compileClasspath")); + assert!(result.contains("runtimeClasspath")); + assert!(result.contains("spring-boot-starter-web")); + + let savings = 100.0 - (count_tokens(&result) as f64 / count_tokens(output) as f64 * 100.0); + assert!( + savings >= 30.0, + "Expected ≥30% savings, got {:.1}%", + savings + ); + } + + #[test] + fn test_extract_test_result() { + assert_eq!( + extract_test_result("com.example.AppTest > testAdd PASSED"), + Some(TestStatus::Passed) + ); + assert_eq!( + extract_test_result("com.example.AppTest > testBroken FAILED"), + Some(TestStatus::Failed) + ); + assert_eq!( + extract_test_result("com.example.AppTest > testDisabled SKIPPED"), + Some(TestStatus::Skipped) + ); + assert_eq!(extract_test_result("some random line"), None); + assert_eq!(extract_test_result("> Task :test"), None); + } + + #[test] + fn test_parse_test_summary_line() { + assert_eq!( + parse_test_summary_line("100 tests completed, 2 failed, 3 skipped"), + Some((100, 2, 3)) + ); + assert_eq!( + parse_test_summary_line("50 tests completed, 1 failed"), + Some((50, 1, 0)) + ); + assert_eq!( + parse_test_summary_line("10 tests completed"), + Some((10, 0, 0)) + ); + } + + #[test] + fn test_filter_gradle_build_with_warnings() { + let output = r#"> Task :compileJava +w: src/main/java/App.java:5: warning: [deprecation] method is deprecated +w: src/main/java/App.java:12: warning: [unchecked] unchecked conversion +> Task :build + +BUILD SUCCESSFUL in 3s +3 actionable tasks: 3 executed"#; + + let result = filter_gradle_build(output); + assert!(result.contains("✓ Gradle")); + assert!(result.contains("2 warning")); + assert!(result.contains("deprecation")); + } + + #[test] + fn test_gradle_build_token_savings() { + let output = r#"Starting a Gradle Daemon, 1 incompatible Daemon could not be reused, use --status for details + +Welcome to Gradle 8.5! + +Here are the highlights of this release: + - Support for running on Java 21 + - Improved error messages + - Type-safe project accessors + +See https://docs.gradle.org/8.5/release-notes.html + +> Task :compileJava UP-TO-DATE +> Task :processResources UP-TO-DATE +> Task :classes UP-TO-DATE +> Task :compileTestJava UP-TO-DATE +> Task :processTestResources UP-TO-DATE +> Task :testClasses UP-TO-DATE +> Task :test UP-TO-DATE +> Task :check UP-TO-DATE +> Task :build UP-TO-DATE + +BUILD SUCCESSFUL in 500ms +8 actionable tasks: 8 up-to-date"#; + + let result = filter_gradle_build(output); + let input_tokens = count_tokens(output); + let output_tokens = count_tokens(&result); + let savings = 100.0 - (output_tokens as f64 / input_tokens as f64 * 100.0); + + assert!( + savings >= 70.0, + "Expected ≥70% savings on verbose Gradle build, got {:.1}% (input: {}, output: {})", + savings, + input_tokens, + output_tokens + ); + } +} diff --git a/src/main.rs b/src/main.rs index 7125464b..b7028c60 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,6 +17,7 @@ mod gh_cmd; mod git; mod go_cmd; mod golangci_cmd; +mod gradle_cmd; mod grep_cmd; mod hook_audit_cmd; mod init; @@ -26,6 +27,7 @@ mod lint_cmd; mod local_llm; mod log_cmd; mod ls; +mod mvn_cmd; mod mypy_cmd; mod next_cmd; mod npm_cmd; @@ -533,6 +535,18 @@ enum Commands { command: GoCommands, }, + /// Gradle commands with compact output (auto-detects ./gradlew) + Gradle { + #[command(subcommand)] + command: GradleCommands, + }, + + /// Maven commands with compact output (auto-detects ./mvnw) + Mvn { + #[command(subcommand)] + command: MvnCommands, + }, + /// golangci-lint with compact output #[command(name = "golangci-lint")] GolangciLint { @@ -863,6 +877,87 @@ enum GoCommands { Other(Vec), } +#[derive(Subcommand)] +enum GradleCommands { + /// Build with compact output (strip task noise, keep errors) + Build { + /// Additional gradle build arguments + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Test with failures-only output (90% token reduction) + Test { + /// Additional gradle test arguments + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Clean with compact output + Clean { + /// Additional gradle clean arguments + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Assemble with compact output + Assemble { + /// Additional gradle assemble arguments + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Dependencies with compact tree + Dependencies { + /// Additional gradle dependencies arguments + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Passthrough: runs any unsupported gradle subcommand directly + #[command(external_subcommand)] + Other(Vec), +} + +#[derive(Subcommand)] +enum MvnCommands { + /// Compile with compact output (strip [INFO] noise, keep errors) + Compile { + /// Additional mvn compile arguments + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Test with failures-only output (90% token reduction) + Test { + /// Additional mvn test arguments + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Package with compact output + Package { + /// Additional mvn package arguments + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Install with compact output + Install { + /// Additional mvn install arguments + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Clean with compact output + Clean { + /// Additional mvn clean arguments + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Dependency tree with compact output + #[command(name = "dependency:tree")] + DependencyTree { + /// Additional mvn dependency:tree arguments + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Passthrough: runs any unsupported mvn subcommand directly + #[command(external_subcommand)] + Other(Vec), +} + fn main() -> Result<()> { let cli = Cli::parse(); @@ -1440,6 +1535,51 @@ fn main() -> Result<()> { } }, + Commands::Gradle { command } => match command { + GradleCommands::Build { args } => { + gradle_cmd::run_build(&args, cli.verbose)?; + } + GradleCommands::Test { args } => { + gradle_cmd::run_test(&args, cli.verbose)?; + } + GradleCommands::Clean { args } => { + gradle_cmd::run_clean(&args, cli.verbose)?; + } + GradleCommands::Assemble { args } => { + gradle_cmd::run_assemble(&args, cli.verbose)?; + } + GradleCommands::Dependencies { args } => { + gradle_cmd::run_dependencies(&args, cli.verbose)?; + } + GradleCommands::Other(args) => { + gradle_cmd::run_other(&args, cli.verbose)?; + } + }, + + Commands::Mvn { command } => match command { + MvnCommands::Compile { args } => { + mvn_cmd::run_compile(&args, cli.verbose)?; + } + MvnCommands::Test { args } => { + mvn_cmd::run_test(&args, cli.verbose)?; + } + MvnCommands::Package { args } => { + mvn_cmd::run_package(&args, cli.verbose)?; + } + MvnCommands::Install { args } => { + mvn_cmd::run_install(&args, cli.verbose)?; + } + MvnCommands::Clean { args } => { + mvn_cmd::run_clean(&args, cli.verbose)?; + } + MvnCommands::DependencyTree { args } => { + mvn_cmd::run_dependency_tree(&args, cli.verbose)?; + } + MvnCommands::Other(args) => { + mvn_cmd::run_other(&args, cli.verbose)?; + } + }, + Commands::GolangciLint { args } => { golangci_cmd::run(&args, cli.verbose)?; } diff --git a/src/mvn_cmd.rs b/src/mvn_cmd.rs new file mode 100644 index 00000000..4b01eddf --- /dev/null +++ b/src/mvn_cmd.rs @@ -0,0 +1,1017 @@ +use crate::tracking; +use crate::utils::truncate; +use anyhow::{Context, Result}; +use std::ffi::OsString; +use std::path::Path; +use std::process::Command; + +/// Detect whether to use ./mvnw (wrapper) or mvn +fn mvn_executable() -> &'static str { + if Path::new("./mvnw").exists() { + "./mvnw" + } else { + "mvn" + } +} + +/// Generic Maven command runner with filtering +fn run_mvn_filtered(subcommand: &str, args: &[String], verbose: u8, filter_fn: F) -> Result<()> +where + F: Fn(&str) -> String, +{ + let timer = tracking::TimedExecution::start(); + let mvn = mvn_executable(); + + let mut cmd = Command::new(mvn); + cmd.arg(subcommand); + for arg in args { + cmd.arg(arg); + } + + if verbose > 0 { + eprintln!("Running: {} {} {}", mvn, subcommand, args.join(" ")); + } + + let output = cmd.output().with_context(|| { + format!( + "Failed to run {} {}. Is Maven installed or is ./mvnw available?", + mvn, subcommand + ) + })?; + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let raw = format!("{}\n{}", stdout, stderr); + + let exit_code = output + .status + .code() + .unwrap_or(if output.status.success() { 0 } else { 1 }); + let filtered = filter_fn(&raw); + + if let Some(hint) = crate::tee::tee_and_hint(&raw, &format!("mvn_{}", subcommand), exit_code) { + println!("{}\n{}", filtered, hint); + } else { + println!("{}", filtered); + } + + timer.track( + &format!("mvn {} {}", subcommand, args.join(" ")), + &format!("rtk mvn {} {}", subcommand, args.join(" ")), + &raw, + &filtered, + ); + + if !output.status.success() { + std::process::exit(exit_code); + } + + Ok(()) +} + +pub fn run_compile(args: &[String], verbose: u8) -> Result<()> { + run_mvn_filtered("compile", args, verbose, filter_mvn_build) +} + +pub fn run_test(args: &[String], verbose: u8) -> Result<()> { + run_mvn_filtered("test", args, verbose, filter_mvn_test) +} + +pub fn run_package(args: &[String], verbose: u8) -> Result<()> { + run_mvn_filtered("package", args, verbose, filter_mvn_build) +} + +pub fn run_install(args: &[String], verbose: u8) -> Result<()> { + run_mvn_filtered("install", args, verbose, filter_mvn_build) +} + +pub fn run_clean(args: &[String], verbose: u8) -> Result<()> { + run_mvn_filtered("clean", args, verbose, filter_mvn_build) +} + +pub fn run_dependency_tree(args: &[String], verbose: u8) -> Result<()> { + run_mvn_filtered("dependency:tree", args, verbose, filter_mvn_dependency_tree) +} + +pub fn run_other(args: &[OsString], verbose: u8) -> Result<()> { + if args.is_empty() { + anyhow::bail!("mvn: no subcommand specified"); + } + + let timer = tracking::TimedExecution::start(); + let mvn = mvn_executable(); + let subcommand = args[0].to_string_lossy(); + + let mut cmd = Command::new(mvn); + cmd.arg(&*subcommand); + for arg in &args[1..] { + cmd.arg(arg); + } + + if verbose > 0 { + eprintln!("Running: {} {} ...", mvn, subcommand); + } + + let output = cmd + .output() + .with_context(|| format!("Failed to run {} {}", mvn, subcommand))?; + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let raw = format!("{}\n{}", stdout, stderr); + + print!("{}", stdout); + eprint!("{}", stderr); + + timer.track( + &format!("mvn {}", subcommand), + &format!("rtk mvn {}", subcommand), + &raw, + &raw, + ); + + if !output.status.success() { + std::process::exit(output.status.code().unwrap_or(1)); + } + + Ok(()) +} + +/// Filter Maven build/compile/package/install output. +/// Strips [INFO] noise, download progress, keeps errors, warnings, and BUILD result. +fn filter_mvn_build(output: &str) -> String { + let mut errors: Vec = Vec::new(); + let mut warnings: Vec = Vec::new(); + let mut build_result = String::new(); + let mut in_error_block = false; + let mut current_error: Vec = Vec::new(); + let mut total_time = String::new(); + + for line in output.lines() { + let trimmed = line.trim(); + + // Skip empty lines + if trimmed.is_empty() { + continue; + } + + // Strip [INFO], [WARNING], [ERROR] prefixes for analysis + let content = strip_mvn_prefix(trimmed); + + // Skip noise: download progress, artifact resolution + if content.starts_with("Downloading from") + || content.starts_with("Downloaded from") + || content.starts_with("Progress") + || content.starts_with("Downloading:") + || content.starts_with("Downloaded:") + || content.contains("kB downloaded") + || content.contains("KB downloaded") + || content.starts_with("---") + || content == "---" + || content.starts_with("Scanning for projects") + || content.starts_with("Using the builder") + || content.starts_with("Nothing to compile") + { + continue; + } + + // Skip separator lines + if content.chars().all(|c| c == '-' || c == '=') && content.len() > 3 { + continue; + } + + // Skip Apache Maven banner + if content.starts_with("Apache Maven") + || content.starts_with("Maven home:") + || content.starts_with("Java version:") + || content.starts_with("Java home:") + || content.starts_with("Default locale:") + || content.starts_with("OS name:") + { + continue; + } + + // Skip compilation/resource processing [INFO] noise + if trimmed.starts_with("[INFO]") { + // Keep BUILD SUCCESS/FAILURE + if content.starts_with("BUILD SUCCESS") || content.starts_with("BUILD FAILURE") { + build_result = content.to_string(); + continue; + } + + // Capture total time + if content.starts_with("Total time:") { + total_time = content.to_string(); + continue; + } + + // Skip verbose module info + if content.starts_with("Building ") + || content.starts_with("Compiling ") + || content.starts_with("Copying ") + || content.starts_with("Changes detected") + || content.starts_with("No sources to compile") + || content.starts_with("skip non existing") + || content.starts_with("Replacing ") + || content.starts_with("Including ") + || content.starts_with("Building jar:") + || content.starts_with("Installing ") + || content.starts_with("Surefire report") + || content.starts_with("Tests run:") + || content.starts_with("Reactor Summary") + || content.starts_with("Reactor Build Order") + || content.contains("SUCCESS") + || content.contains("SKIPPED") + { + // Keep "Tests run:" summary line from surefire + if content.starts_with("Tests run:") { + // This is handled by test filter, skip in build filter + } + continue; + } + + // Skip all other [INFO] lines (most verbose noise) + continue; + } + + // Capture [ERROR] blocks + if trimmed.starts_with("[ERROR]") { + if !in_error_block { + // Flush previous error + if !current_error.is_empty() { + errors.push(current_error.join("\n")); + current_error.clear(); + } + in_error_block = true; + } + current_error.push(content.to_string()); + continue; + } + + // End of error block on non-error line + if in_error_block && !trimmed.starts_with("[ERROR]") { + if !current_error.is_empty() { + errors.push(current_error.join("\n")); + current_error.clear(); + } + in_error_block = false; + } + + // Capture [WARNING] lines + if trimmed.starts_with("[WARNING]") { + warnings.push(content.to_string()); + continue; + } + } + + // Flush any remaining error + if !current_error.is_empty() { + errors.push(current_error.join("\n")); + } + + let mut result = String::new(); + + // Success case + if build_result.contains("SUCCESS") && errors.is_empty() { + result.push_str(&format!("✓ Maven: {}", build_result)); + if !total_time.is_empty() { + result.push_str(&format!( + " ({})", + total_time.trim_start_matches("Total time: ") + )); + } + + if !warnings.is_empty() { + result.push_str(&format!( + "\n{} warning{}", + warnings.len(), + if warnings.len() > 1 { "s" } else { "" } + )); + for w in warnings.iter().take(5) { + result.push_str(&format!("\n {}", truncate(w, 120))); + } + if warnings.len() > 5 { + result.push_str(&format!("\n ... +{} more", warnings.len() - 5)); + } + } + + return result; + } + + // Failure case + if !errors.is_empty() || build_result.contains("FAILURE") { + result.push_str(&format!( + "Maven build: {} error{}", + errors.len(), + if errors.len() > 1 { "s" } else { "" } + )); + if !warnings.is_empty() { + result.push_str(&format!(", {} warnings", warnings.len())); + } + result.push('\n'); + result.push_str("═══════════════════════════════════════\n"); + + for (i, error) in errors.iter().take(10).enumerate() { + result.push_str(&format!("{}. {}\n", i + 1, truncate(error, 200))); + } + if errors.len() > 10 { + result.push_str(&format!("\n... +{} more errors\n", errors.len() - 10)); + } + + return result.trim().to_string(); + } + + // No build result found + if output.trim().is_empty() { + return "✓ Maven: Complete".to_string(); + } + + // Fallback: abbreviated output + let lines: Vec<&str> = output.lines().collect(); + if lines.len() <= 5 { + return output.trim().to_string(); + } + + result.push_str(&format!("Maven: {} lines of output\n", lines.len())); + for line in lines.iter().take(5) { + result.push_str(&format!(" {}\n", truncate(line, 120))); + } + result.push_str(&format!(" ... +{} more lines", lines.len() - 5)); + result.trim().to_string() +} + +/// Filter Maven test output (mvn test / mvn verify). +/// Shows only failures and summary, strips per-test noise. +fn filter_mvn_test(output: &str) -> String { + let mut total_tests = 0; + let mut total_failures = 0; + let mut total_errors = 0; + let mut total_skipped = 0; + let mut failed_tests: Vec<(String, Vec)> = Vec::new(); + let mut in_failure_block = false; + let mut current_failure_name = String::new(); + let mut current_failure_lines: Vec = Vec::new(); + let mut build_result = String::new(); + let mut total_time = String::new(); + let mut seen_summary = false; + + for line in output.lines() { + let trimmed = line.trim(); + + if trimmed.is_empty() { + continue; + } + + let content = strip_mvn_prefix(trimmed); + + // Skip noise + if content.starts_with("Downloading from") + || content.starts_with("Downloaded from") + || content.starts_with("Scanning for projects") + || content.starts_with("Using the builder") + || content.starts_with("Surefire report") + || content.starts_with("Apache Maven") + || content.starts_with("Maven home:") + || content.starts_with("Java version:") + || content.starts_with("Java home:") + || content.starts_with("Default locale:") + || content.starts_with("OS name:") + { + continue; + } + + // Skip separator lines + if content.chars().all(|c| c == '-' || c == '=') && content.len() > 3 { + continue; + } + + // BUILD result + if content.starts_with("BUILD SUCCESS") || content.starts_with("BUILD FAILURE") { + build_result = content.to_string(); + continue; + } + + // Total time + if content.starts_with("Total time:") { + total_time = content.to_string(); + continue; + } + + // Parse Surefire/Failsafe summary: "Tests run: 15, Failures: 2, Errors: 0, Skipped: 1" + // Skip per-class lines (contain "-- in ClassName"), only use final aggregate summary + if content.starts_with("Tests run:") && content.contains("Failures:") { + if !content.contains(" -- in ") { + if let Some((t, f, e, s)) = parse_surefire_summary(&content) { + total_tests += t; + total_failures += f; + total_errors += e; + total_skipped += s; + seen_summary = true; + } + } + continue; + } + + // Detect test failure marker (Surefire format) + // Lines like: "testMethodName(com.example.AppTest) Time elapsed: 0.001 s <<< FAILURE!" + // or: "testMethodName(com.example.AppTest) Time elapsed: 0.001 s <<< ERROR!" + if content.contains("<<< FAILURE!") || content.contains("<<< ERROR!") { + // Flush previous failure + if !current_failure_name.is_empty() { + failed_tests.push((current_failure_name.clone(), current_failure_lines.clone())); + current_failure_lines.clear(); + } + // Extract test name + current_failure_name = content + .split("Time elapsed") + .next() + .unwrap_or(&content) + .trim() + .to_string(); + in_failure_block = true; + continue; + } + + // Collect failure output lines + if in_failure_block { + // End of failure block on next test or [INFO]/[ERROR] marker + if content.starts_with("Tests run:") + || (trimmed.starts_with("[INFO]") + && (content.starts_with("Building ") + || content.starts_with("Compiling ") + || content.starts_with("Reactor"))) + { + if !current_failure_name.is_empty() { + failed_tests + .push((current_failure_name.clone(), current_failure_lines.clone())); + current_failure_name.clear(); + current_failure_lines.clear(); + } + in_failure_block = false; + // Re-process this line (it might be a summary) + if content.starts_with("Tests run:") + && content.contains("Failures:") + && !content.contains(" -- in ") + { + if let Some((t, f, e, s)) = parse_surefire_summary(&content) { + total_tests += t; + total_failures += f; + total_errors += e; + total_skipped += s; + seen_summary = true; + } + } + continue; + } + current_failure_lines.push(content.to_string()); + continue; + } + } + + // Flush last failure + if !current_failure_name.is_empty() { + failed_tests.push((current_failure_name, current_failure_lines)); + } + + let total_fail = total_failures + total_errors; + let passed = if total_tests >= total_fail + total_skipped { + total_tests - total_fail - total_skipped + } else { + 0 + }; + + // No test results found + if !seen_summary && total_tests == 0 { + if build_result.contains("SUCCESS") { + return "✓ Maven test: No tests found (BUILD SUCCESS)".to_string(); + } + if build_result.contains("FAILURE") { + return filter_mvn_build(output); + } + return "Maven test: No test results found".to_string(); + } + + // All passed + if total_fail == 0 { + let mut result = format!("✓ Maven test: {} passed", passed); + if total_skipped > 0 { + result.push_str(&format!(", {} skipped", total_skipped)); + } + if !total_time.is_empty() { + result.push_str(&format!( + " ({})", + total_time.trim_start_matches("Total time: ") + )); + } + return result; + } + + // Has failures + let mut result = String::new(); + result.push_str(&format!( + "Maven test: {} passed, {} failed", + passed, total_fail + )); + if total_skipped > 0 { + result.push_str(&format!(", {} skipped", total_skipped)); + } + result.push('\n'); + result.push_str("═══════════════════════════════════════\n"); + + for (test_name, output_lines) in failed_tests.iter().take(10) { + result.push_str(&format!(" ❌ {}\n", test_name)); + + // Show relevant failure lines + let relevant: Vec<&String> = output_lines + .iter() + .filter(|l| { + let lower = l.to_lowercase(); + !l.trim().is_empty() + && (lower.contains("expected") + || lower.contains("actual") + || lower.contains("assert") + || lower.contains("error") + || lower.contains("exception") + || lower.contains("but was") + || lower.contains("at ")) + }) + .take(5) + .collect(); + + for line in relevant { + result.push_str(&format!(" {}\n", truncate(line, 100))); + } + } + + if failed_tests.len() > 10 { + result.push_str(&format!( + "\n... +{} more failed tests\n", + failed_tests.len() - 10 + )); + } + + result.trim().to_string() +} + +/// Filter Maven dependency:tree output. +/// Compacts the tree, showing only direct dependencies. +fn filter_mvn_dependency_tree(output: &str) -> String { + let mut direct_deps: Vec = Vec::new(); + let mut in_tree = false; + let mut project_name = String::new(); + + for line in output.lines() { + let trimmed = line.trim(); + + if trimmed.is_empty() { + continue; + } + + let content = strip_mvn_prefix(trimmed); + + // Skip noise + if content.starts_with("Downloading from") + || content.starts_with("Downloaded from") + || content.starts_with("Scanning for projects") + || content.starts_with("Building ") + || content.starts_with("Apache Maven") + || content.starts_with("Maven home:") + || content.starts_with("Java version:") + { + continue; + } + + // Skip separator and empty content lines + if content.chars().all(|c| c == '-' || c == '=') && content.len() > 3 { + continue; + } + + // Skip BUILD lines + if content.starts_with("BUILD") || content.starts_with("Total time:") { + continue; + } + + // Detect tree header (project GAV) + if content.starts_with("maven-dependency-plugin") + || content.contains(":tree") + || content.contains("Verbose not supported") + { + in_tree = true; + continue; + } + + // Detect project root in tree (no leading tree chars) + if in_tree + && !content.starts_with('+') + && !content.starts_with('|') + && !content.starts_with('\\') + && !content.starts_with(' ') + && content.contains(':') + && !content.starts_with('[') + { + project_name = content.to_string(); + continue; + } + + // Collect direct dependencies (first level: "+- " or "\- ") + if in_tree && (content.starts_with("+- ") || content.starts_with("\\- ")) { + let dep = content + .trim_start_matches("+- ") + .trim_start_matches("\\- ") + .to_string(); + direct_deps.push(dep); + } + } + + if direct_deps.is_empty() { + return "Maven dependencies: No dependencies found".to_string(); + } + + let mut result = String::new(); + if !project_name.is_empty() { + result.push_str(&format!("Maven dependencies for {}:\n", project_name)); + } else { + result.push_str("Maven dependencies:\n"); + } + result.push_str(&format!("{} direct dependencies\n", direct_deps.len())); + result.push_str("═══════════════════════════════════════\n"); + + for dep in direct_deps.iter().take(30) { + result.push_str(&format!(" {}\n", truncate(dep, 100))); + } + if direct_deps.len() > 30 { + result.push_str(&format!(" ... +{} more\n", direct_deps.len() - 30)); + } + + result.trim().to_string() +} + +/// Strip Maven log level prefix: "[INFO] ", "[WARNING] ", "[ERROR] " +fn strip_mvn_prefix(line: &str) -> &str { + let trimmed = line.trim(); + if let Some(rest) = trimmed.strip_prefix("[INFO] ") { + rest + } else if let Some(rest) = trimmed.strip_prefix("[WARNING] ") { + rest + } else if let Some(rest) = trimmed.strip_prefix("[ERROR] ") { + rest + } else if trimmed == "[INFO]" || trimmed == "[WARNING]" || trimmed == "[ERROR]" { + "" + } else { + trimmed + } +} + +/// Parse Surefire summary line: "Tests run: 15, Failures: 2, Errors: 0, Skipped: 1" +fn parse_surefire_summary(line: &str) -> Option<(usize, usize, usize, usize)> { + let tests = extract_number_after(line, "Tests run:")?; + let failures = extract_number_after(line, "Failures:").unwrap_or(0); + let errors = extract_number_after(line, "Errors:").unwrap_or(0); + let skipped = extract_number_after(line, "Skipped:").unwrap_or(0); + Some((tests, failures, errors, skipped)) +} + +/// Extract a number after a label like "Tests run: 15," +fn extract_number_after(line: &str, label: &str) -> Option { + let after = line.split(label).nth(1)?; + let num_str = after.trim().split(|c: char| !c.is_ascii_digit()).next()?; + num_str.parse().ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn count_tokens(text: &str) -> usize { + text.split_whitespace().count() + } + + #[test] + fn test_filter_mvn_build_success() { + let output = r#"[INFO] Scanning for projects... +[INFO] +[INFO] ----------------------< com.example:myapp >----------------------- +[INFO] Building myapp 1.0-SNAPSHOT +[INFO] --------------------------------[ jar ]--------------------------------- +[INFO] +[INFO] --- maven-resources-plugin:3.3.1:resources (default-resources) @ myapp --- +[INFO] Copying 1 resource from src/main/resources to target/classes +[INFO] +[INFO] --- maven-compiler-plugin:3.11.0:compile (default-compile) @ myapp --- +[INFO] Nothing to compile - all classes are up to date. +[INFO] +[INFO] --- maven-resources-plugin:3.3.1:testResources (default-testResources) @ myapp --- +[INFO] skip non existing resourceDirectory /home/user/myapp/src/test/resources +[INFO] +[INFO] --- maven-compiler-plugin:3.11.0:testCompile (default-testCompile) @ myapp --- +[INFO] No sources to compile. +[INFO] +[INFO] --- maven-jar-plugin:3.3.0:jar (default-jar) @ myapp --- +[INFO] Building jar: /home/user/myapp/target/myapp-1.0-SNAPSHOT.jar +[INFO] +[INFO] --- maven-install-plugin:3.1.1:install (default-install) @ myapp --- +[INFO] Installing /home/user/myapp/target/myapp-1.0-SNAPSHOT.jar to /home/user/.m2/repository/com/example/myapp/1.0-SNAPSHOT/myapp-1.0-SNAPSHOT.jar +[INFO] ------------------------------------------------------------------------ +[INFO] BUILD SUCCESS +[INFO] ------------------------------------------------------------------------ +[INFO] Total time: 2.345 s +[INFO] Finished at: 2026-02-28T10:30:00Z +[INFO] ------------------------------------------------------------------------"#; + + let result = filter_mvn_build(output); + assert!(result.contains("✓ Maven")); + assert!(result.contains("BUILD SUCCESS")); + assert!(result.contains("2.345 s")); + + let savings = 100.0 - (count_tokens(&result) as f64 / count_tokens(output) as f64 * 100.0); + assert!( + savings >= 80.0, + "Expected ≥80% savings, got {:.1}%", + savings + ); + } + + #[test] + fn test_filter_mvn_build_with_errors() { + let output = r#"[INFO] Scanning for projects... +[INFO] ----------------------< com.example:myapp >----------------------- +[INFO] Building myapp 1.0-SNAPSHOT +[INFO] --------------------------------[ jar ]--------------------------------- +[INFO] --- maven-compiler-plugin:3.11.0:compile (default-compile) @ myapp --- +[ERROR] /src/main/java/App.java:[10,5] error: cannot find symbol +[ERROR] /src/main/java/App.java:[15,2] error: incompatible types +[INFO] ------------------------------------------------------------------------ +[INFO] BUILD FAILURE +[INFO] ------------------------------------------------------------------------ +[INFO] Total time: 1.234 s"#; + + let result = filter_mvn_build(output); + assert!(result.contains("error")); + assert!(result.contains("Maven build:")); + assert!(!result.contains("Scanning for projects")); + } + + #[test] + fn test_filter_mvn_build_empty() { + let result = filter_mvn_build(""); + assert!(result.contains("✓ Maven")); + } + + #[test] + fn test_filter_mvn_test_all_pass() { + let output = r#"[INFO] Scanning for projects... +[INFO] ----------------------< com.example:myapp >----------------------- +[INFO] Building myapp 1.0-SNAPSHOT +[INFO] --------------------------------[ jar ]--------------------------------- +[INFO] --- maven-compiler-plugin:3.11.0:compile (default-compile) @ myapp --- +[INFO] Nothing to compile - all classes are up to date. +[INFO] --- maven-compiler-plugin:3.11.0:testCompile (default-testCompile) @ myapp --- +[INFO] Nothing to compile - all classes are up to date. +[INFO] --- maven-surefire-plugin:3.2.3:test (default-test) @ myapp --- +[INFO] Using auto detected provider org.apache.maven.surefire.junitplatform.JUnitPlatformProvider +[INFO] +[INFO] ------------------------------------------------------- +[INFO] T E S T S +[INFO] ------------------------------------------------------- +[INFO] Running com.example.AppTest +[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.123 s -- in com.example.AppTest +[INFO] Running com.example.UtilsTest +[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.045 s -- in com.example.UtilsTest +[INFO] +[INFO] Results: +[INFO] +[INFO] Tests run: 8, Failures: 0, Errors: 0, Skipped: 0 +[INFO] +[INFO] ------------------------------------------------------------------------ +[INFO] BUILD SUCCESS +[INFO] ------------------------------------------------------------------------ +[INFO] Total time: 4.567 s +[INFO] Finished at: 2026-02-28T10:30:00Z +[INFO] ------------------------------------------------------------------------"#; + + let result = filter_mvn_test(output); + assert!(result.contains("✓ Maven test")); + assert!(result.contains("8 passed")); + assert!(result.contains("4.567 s")); + + let savings = 100.0 - (count_tokens(&result) as f64 / count_tokens(output) as f64 * 100.0); + assert!( + savings >= 80.0, + "Expected ≥80% savings, got {:.1}%", + savings + ); + } + + #[test] + fn test_filter_mvn_test_with_failures() { + let output = r#"[INFO] ------------------------------------------------------- +[INFO] T E S T S +[INFO] ------------------------------------------------------- +[INFO] Running com.example.AppTest +[ERROR] Tests run: 3, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.234 s <<< FAILURE! -- in com.example.AppTest +[ERROR] testBroken(com.example.AppTest) Time elapsed: 0.012 s <<< FAILURE! +org.opentest4j.AssertionFailedError: expected: <5> but was: <3> + at org.junit.jupiter.api.AssertEquals.assertEquals(AssertEquals.java:166) + at com.example.AppTest.testBroken(AppTest.java:25) + +[INFO] +[INFO] Results: +[INFO] +[ERROR] Tests run: 3, Failures: 1, Errors: 0, Skipped: 0 +[INFO] +[INFO] ------------------------------------------------------------------------ +[INFO] BUILD FAILURE +[INFO] ------------------------------------------------------------------------ +[INFO] Total time: 3.456 s"#; + + let result = filter_mvn_test(output); + assert!(result.contains("failed")); + assert!(result.contains("testBroken")); + assert!(result.contains("expected: <5> but was: <3>")); + } + + #[test] + fn test_filter_mvn_test_with_skipped() { + let output = r#"[INFO] ------------------------------------------------------- +[INFO] T E S T S +[INFO] ------------------------------------------------------- +[INFO] Running com.example.AppTest +[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 2, Time elapsed: 0.1 s -- in com.example.AppTest +[INFO] +[INFO] Results: +[INFO] +[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 2 +[INFO] +[INFO] ------------------------------------------------------------------------ +[INFO] BUILD SUCCESS +[INFO] ------------------------------------------------------------------------ +[INFO] Total time: 2.0 s"#; + + let result = filter_mvn_test(output); + assert!(result.contains("✓ Maven test")); + assert!(result.contains("3 passed")); + assert!(result.contains("2 skipped")); + } + + #[test] + fn test_filter_mvn_test_no_tests() { + let output = r#"[INFO] Scanning for projects... +[INFO] ----------------------< com.example:myapp >----------------------- +[INFO] Building myapp 1.0-SNAPSHOT +[INFO] No tests to run. +[INFO] ------------------------------------------------------------------------ +[INFO] BUILD SUCCESS +[INFO] ------------------------------------------------------------------------"#; + + let result = filter_mvn_test(output); + assert!(result.contains("No tests found")); + } + + #[test] + fn test_filter_mvn_dependency_tree() { + let output = r#"[INFO] Scanning for projects... +[INFO] ----------------------< com.example:myapp >----------------------- +[INFO] Building myapp 1.0-SNAPSHOT +[INFO] --------------------------------[ jar ]--------------------------------- +[INFO] +[INFO] --- maven-dependency-plugin:3.6.1:tree (default-cli) @ myapp --- +[INFO] com.example:myapp:jar:1.0-SNAPSHOT +[INFO] +- org.springframework.boot:spring-boot-starter-web:jar:3.2.0:compile +[INFO] | +- org.springframework.boot:spring-boot-starter:jar:3.2.0:compile +[INFO] | +- org.springframework.boot:spring-boot-starter-json:jar:3.2.0:compile +[INFO] | \- org.springframework.boot:spring-boot-starter-tomcat:jar:3.2.0:compile +[INFO] +- com.google.guava:guava:jar:32.1.3-jre:compile +[INFO] | +- com.google.guava:failureaccess:jar:1.0.1:compile +[INFO] | \- com.google.guava:listenablefuture:jar:9999.0-empty-to-avoid-conflict-with-guava:compile +[INFO] \- org.projectlombok:lombok:jar:1.18.30:provided +[INFO] ------------------------------------------------------------------------ +[INFO] BUILD SUCCESS +[INFO] ------------------------------------------------------------------------ +[INFO] Total time: 1.234 s"#; + + let result = filter_mvn_dependency_tree(output); + assert!(result.contains("Maven dependencies")); + assert!(result.contains("3 direct dependencies")); + assert!(result.contains("spring-boot-starter-web")); + assert!(result.contains("guava")); + assert!(result.contains("lombok")); + // Should not include transitive deps + assert!(!result.contains("failureaccess")); + + let savings = 100.0 - (count_tokens(&result) as f64 / count_tokens(output) as f64 * 100.0); + assert!( + savings >= 40.0, + "Expected ≥40% savings, got {:.1}%", + savings + ); + } + + #[test] + fn test_strip_mvn_prefix() { + assert_eq!(strip_mvn_prefix("[INFO] BUILD SUCCESS"), "BUILD SUCCESS"); + assert_eq!( + strip_mvn_prefix("[WARNING] Using deprecated API"), + "Using deprecated API" + ); + assert_eq!( + strip_mvn_prefix("[ERROR] Compilation failed"), + "Compilation failed" + ); + assert_eq!(strip_mvn_prefix("[INFO]"), ""); + assert_eq!(strip_mvn_prefix("plain text"), "plain text"); + } + + #[test] + fn test_parse_surefire_summary() { + assert_eq!( + parse_surefire_summary("Tests run: 15, Failures: 2, Errors: 1, Skipped: 3"), + Some((15, 2, 1, 3)) + ); + assert_eq!( + parse_surefire_summary("Tests run: 10, Failures: 0, Errors: 0, Skipped: 0"), + Some((10, 0, 0, 0)) + ); + } + + #[test] + fn test_extract_number_after() { + assert_eq!( + extract_number_after("Tests run: 15, Failures: 2", "Tests run:"), + Some(15) + ); + assert_eq!( + extract_number_after("Tests run: 15, Failures: 2", "Failures:"), + Some(2) + ); + assert_eq!(extract_number_after("no match here", "Tests run:"), None); + } + + #[test] + fn test_mvn_build_token_savings() { + let output = r#"[INFO] Scanning for projects... +[INFO] +[INFO] ----------------------< com.example:myapp >----------------------- +[INFO] Building myapp 1.0-SNAPSHOT +[INFO] --------------------------------[ jar ]--------------------------------- +[INFO] +[INFO] --- maven-resources-plugin:3.3.1:resources (default-resources) @ myapp --- +[INFO] Copying 1 resource from src/main/resources to target/classes +[INFO] +[INFO] --- maven-compiler-plugin:3.11.0:compile (default-compile) @ myapp --- +[INFO] Compiling 15 source files to /home/user/myapp/target/classes +[INFO] +[INFO] --- maven-resources-plugin:3.3.1:testResources (default-testResources) @ myapp --- +[INFO] Copying 2 resources from src/test/resources to target/test-classes +[INFO] +[INFO] --- maven-compiler-plugin:3.11.0:testCompile (default-testCompile) @ myapp --- +[INFO] Compiling 8 source files to /home/user/myapp/target/test-classes +[INFO] +[INFO] --- maven-surefire-plugin:3.2.3:test (default-test) @ myapp --- +[INFO] Using auto detected provider org.apache.maven.surefire.junitplatform.JUnitPlatformProvider +[INFO] +[INFO] --- maven-jar-plugin:3.3.0:jar (default-jar) @ myapp --- +[INFO] Building jar: /home/user/myapp/target/myapp-1.0-SNAPSHOT.jar +[INFO] +[INFO] --- maven-install-plugin:3.1.1:install (default-install) @ myapp --- +[INFO] Installing /home/user/myapp/pom.xml to /home/user/.m2/repository/com/example/myapp/1.0-SNAPSHOT/myapp-1.0-SNAPSHOT.pom +[INFO] Installing /home/user/myapp/target/myapp-1.0-SNAPSHOT.jar to /home/user/.m2/repository/com/example/myapp/1.0-SNAPSHOT/myapp-1.0-SNAPSHOT.jar +[INFO] ------------------------------------------------------------------------ +[INFO] BUILD SUCCESS +[INFO] ------------------------------------------------------------------------ +[INFO] Total time: 5.678 s +[INFO] Finished at: 2026-02-28T10:30:00Z +[INFO] ------------------------------------------------------------------------"#; + + let result = filter_mvn_build(output); + let input_tokens = count_tokens(output); + let output_tokens = count_tokens(&result); + let savings = 100.0 - (output_tokens as f64 / input_tokens as f64 * 100.0); + + assert!( + savings >= 80.0, + "Expected ≥80% savings on verbose Maven build, got {:.1}% (input: {}, output: {})", + savings, + input_tokens, + output_tokens + ); + } + + #[test] + fn test_filter_mvn_build_with_warnings() { + let output = r#"[INFO] Scanning for projects... +[INFO] Building myapp 1.0-SNAPSHOT +[WARNING] Using platform encoding (UTF-8 actually) to copy filtered resources +[WARNING] bootstrap class path not set in conjunction with -source 8 +[INFO] --- maven-compiler-plugin:3.11.0:compile (default-compile) @ myapp --- +[INFO] Compiling 5 source files +[INFO] ------------------------------------------------------------------------ +[INFO] BUILD SUCCESS +[INFO] ------------------------------------------------------------------------ +[INFO] Total time: 1.5 s"#; + + let result = filter_mvn_build(output); + assert!(result.contains("✓ Maven")); + assert!(result.contains("2 warning")); + assert!(result.contains("platform encoding")); + } +}