From 3709dd67c77cbb63aadd7ac62e6e5f47853cdf4f Mon Sep 17 00:00:00 2001 From: William Koller Date: Thu, 23 Apr 2026 16:04:12 -0300 Subject: [PATCH 001/154] feat(hook): add support for Kimi AI agent --- README.md | 2 ++ hooks/kimi/rules.md | 32 +++++++++++++++++++++++ src/hooks/init.rs | 63 +++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 7 +++++ 4 files changed, 104 insertions(+) create mode 100644 hooks/kimi/rules.md diff --git a/README.md b/README.md index 1452b1ca8..79ac95d75 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,7 @@ rtk init --agent windsurf # Windsurf rtk init --agent cline # Cline / Roo Code rtk init --agent kilocode # Kilo Code rtk init --agent antigravity # Google Antigravity +rtk init --agent kimi # Kimi AI # 2. Restart your AI tool, then test git status # Automatically rewritten to rtk git status @@ -367,6 +368,7 @@ RTK supports 12 AI coding tools. Each integration transparently rewrites shell c | **Mistral Vibe** | Planned ([#800](https://github.com/rtk-ai/rtk/issues/800)) | Blocked on upstream | | **Kilo Code** | `rtk init --agent kilocode` | .kilocode/rules/rtk-rules.md (project-scoped) | | **Google Antigravity** | `rtk init --agent antigravity` | .agents/rules/antigravity-rtk-rules.md (project-scoped) | +| **Kimi AI** | `rtk init --agent kimi` | .kimirules (project-scoped) | For per-agent setup details, override controls, and graceful degradation, see the [Supported Agents guide](https://www.rtk-ai.app/guide/getting-started/supported-agents). diff --git a/hooks/kimi/rules.md b/hooks/kimi/rules.md new file mode 100644 index 000000000..55bab19e8 --- /dev/null +++ b/hooks/kimi/rules.md @@ -0,0 +1,32 @@ +# RTK - Rust Token Killer (Kimi AI) + +**Usage**: Token-optimized CLI proxy for shell commands. + +## Rule + +Always prefix shell commands with `rtk` to minimize token consumption. + +Examples: + +```bash +rtk git status +rtk cargo test +rtk ls src/ +rtk grep "pattern" src/ +rtk find "*.rs" . +rtk docker ps +rtk gh pr list +``` + +## Meta Commands + +```bash +rtk gain # Show token savings +rtk gain --history # Command history with savings +rtk discover # Find missed RTK opportunities +rtk proxy # Run raw (no filtering, for debugging) +``` + +## Why + +RTK filters and compresses command output before it reaches the LLM context, saving 60-90% tokens on common operations. Always use `rtk ` instead of raw commands. diff --git a/src/hooks/init.rs b/src/hooks/init.rs index c83185b90..ad9412f90 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -1405,6 +1405,44 @@ fn run_antigravity_mode_at(base_dir: &Path, verbose: u8) -> Result<()> { Ok(()) } +// ─── Kimi AI support ────────────────────────────────────────── + +const KIMI_RULES: &str = include_str!("../../hooks/kimi/rules.md"); + +pub fn run_kimi_mode(verbose: u8) -> Result<()> { + run_kimi_mode_at(&std::env::current_dir()?, verbose) +} + +fn run_kimi_mode_at(base_dir: &Path, verbose: u8) -> Result<()> { + // Kimi AI reads .kimirules from the project root (workspace-scoped) + let rules_path = base_dir.join(".kimirules"); + + let existing = fs::read_to_string(&rules_path).unwrap_or_default(); + if existing.contains("RTK") || existing.contains("rtk") { + println!("\nRTK already configured for Kimi AI in this project.\n"); + println!(" Rules: .kimirules (already present)"); + } else { + let new_content = if existing.trim().is_empty() { + KIMI_RULES.to_string() + } else { + format!("{}\n\n{}", existing.trim(), KIMI_RULES) + }; + fs::write(&rules_path, &new_content) + .context("Failed to write .kimirules")?; + + if verbose > 0 { + eprintln!("Wrote .kimirules"); + } + + println!("\nRTK configured for Kimi AI.\n"); + println!(" Rules: .kimirules (installed)"); + } + println!(" Kimi AI will now use rtk commands for token savings."); + println!(" Test with: git status\n"); + + Ok(()) +} + fn run_codex_mode(global: bool, verbose: u8) -> Result<()> { let (agents_md_path, rtk_md_path) = if global { let codex_dir = resolve_codex_dir()?; @@ -2957,6 +2995,31 @@ More notes assert_eq!(first, second, "Idempotent: content should not change"); } + #[test] + fn test_kimi_mode_creates_rules_file() { + let temp = TempDir::new().unwrap(); + run_kimi_mode_at(temp.path(), 0).unwrap(); + + let rules_path = temp.path().join(".kimirules"); + assert!(rules_path.exists(), "Rules file should be created"); + let content = fs::read_to_string(&rules_path).unwrap(); + assert!(content.contains("RTK"), "Rules file should contain RTK"); + } + + #[test] + fn test_kimi_mode_is_idempotent() { + let temp = TempDir::new().unwrap(); + run_kimi_mode_at(temp.path(), 0).unwrap(); + + let path = temp.path().join(".kimirules"); + let first = fs::read_to_string(&path).unwrap(); + + // Second run should not overwrite + run_kimi_mode_at(temp.path(), 0).unwrap(); + let second = fs::read_to_string(&path).unwrap(); + assert_eq!(first, second, "Idempotent: content should not change"); + } + #[test] fn test_patch_agents_md_creates_missing_file() { let temp = TempDir::new().unwrap(); diff --git a/src/main.rs b/src/main.rs index 82d994910..93cd9bf5f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -44,6 +44,8 @@ pub enum AgentTarget { Kilocode, /// Google Antigravity Antigravity, + /// Kimi AI + Kimi, } #[derive(Parser)] @@ -1785,6 +1787,11 @@ fn run_cli() -> Result { ); } hooks::init::run_antigravity_mode(cli.verbose)?; + } else if agent == Some(AgentTarget::Kimi) { + if global { + anyhow::bail!("Kimi AI is project-scoped. Use: rtk init --agent kimi"); + } + hooks::init::run_kimi_mode(cli.verbose)?; } else { let install_opencode = opencode; let install_claude = !opencode; From 9b8c0b594de09b55c5ed99c86befbe9fbfa90aff Mon Sep 17 00:00:00 2001 From: Ivan Severino <4858703+randomBrainstormer@users.noreply.github.com> Date: Fri, 20 Mar 2026 14:01:38 +0100 Subject: [PATCH 002/154] feat(sbt): add SBT (Scala Build Tool) support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `rtk sbt` command with three subcommands: - `rtk sbt test` — ScalaTest output filtering (90% token reduction): compact single-line on pass, failure details on fail - `rtk sbt compile` — Strips SBT noise, keeps errors (75% token reduction): success summary with source count + time - `rtk sbt run` — Light filtering, strips SBT preamble, keeps program output - `rtk sbt ` — Passthrough for unsupported subcommands Implementation follows the established go_cmd.rs pattern with lazy_static regex, tee output recovery on failure, and exit code propagation for CI/CD. Includes 3 real-output fixtures (pass, fail, compile error) and 11 unit tests with token savings assertions. Discovery rules updated with 80% savings estimate. Signed-off-by: Ivan Severino <4858703+randomBrainstormer@users.noreply.github.com> --- CHANGELOG.md | 2 + README.md | 3 + src/cmds/mod.rs | 1 + src/cmds/scala/mod.rs | 1 + src/cmds/scala/sbt_cmd.rs | 635 +++++++++++++++++++++++ src/discover/rules.rs | 10 + src/main.rs | 43 ++ tests/fixtures/sbt/sbt_compile_error.txt | 20 + tests/fixtures/sbt/sbt_test_fail.txt | 47 ++ tests/fixtures/sbt/sbt_test_pass.txt | 58 +++ 10 files changed, 820 insertions(+) create mode 100644 src/cmds/scala/mod.rs create mode 100644 src/cmds/scala/sbt_cmd.rs create mode 100644 tests/fixtures/sbt/sbt_compile_error.txt create mode 100644 tests/fixtures/sbt/sbt_test_fail.txt create mode 100644 tests/fixtures/sbt/sbt_test_pass.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e2345b8f..6379495fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -429,6 +429,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Bug Fixes * **cargo:** preserve compile diagnostics when `cargo test` fails before any test suites run +* **sbt:** add `rtk sbt` command for SBT (Scala Build Tool) with ScalaTest filtering (90% token reduction on test output, 75% on compile) + ## [0.31.0](https://github.com/rtk-ai/rtk/compare/v0.30.1...v0.31.0) (2026-03-19) diff --git a/README.md b/README.md index f8d65efe5..48f1c8805 100644 --- a/README.md +++ b/README.md @@ -196,6 +196,9 @@ rtk cargo clippy # Cargo clippy (-80%) rtk ruff check # Python linting (JSON, -80%) rtk golangci-lint run # Go linting (JSON, -85%) rtk rubocop # Ruby linting (JSON, -60%+) +rtk sbt test # ScalaTest output (-90%) +rtk sbt compile # Compilation errors only (-75%) +rtk sbt run # Strip SBT preamble noise ``` ### Package Managers diff --git a/src/cmds/mod.rs b/src/cmds/mod.rs index d064182b3..81d437225 100644 --- a/src/cmds/mod.rs +++ b/src/cmds/mod.rs @@ -9,4 +9,5 @@ pub mod jvm; pub mod python; pub mod ruby; pub mod rust; +pub mod scala; pub mod system; diff --git a/src/cmds/scala/mod.rs b/src/cmds/scala/mod.rs new file mode 100644 index 000000000..56b8f5bf6 --- /dev/null +++ b/src/cmds/scala/mod.rs @@ -0,0 +1 @@ +automod::dir!(pub "src/cmds/scala"); diff --git a/src/cmds/scala/sbt_cmd.rs b/src/cmds/scala/sbt_cmd.rs new file mode 100644 index 000000000..0b20d4b4e --- /dev/null +++ b/src/cmds/scala/sbt_cmd.rs @@ -0,0 +1,635 @@ +use crate::core::tracking; +use crate::core::utils::{resolved_command, truncate}; +use anyhow::{Context, Result}; +use lazy_static::lazy_static; +use regex::Regex; +use std::ffi::OsString; + +lazy_static! { + /// Matches the ScalaTest summary line: + /// Tests: succeeded N, failed N, canceled N, ignored N, pending N + static ref TEST_SUMMARY_RE: Regex = Regex::new( + r"Tests: succeeded (\d+), failed (\d+), canceled (\d+), ignored (\d+), pending (\d+)" + ).unwrap(); + + /// Matches suite count line: + /// Suites: completed N, aborted N + static ref SUITE_SUMMARY_RE: Regex = Regex::new( + r"Suites: completed (\d+), aborted (\d+)" + ).unwrap(); + + /// Matches the "Run completed in" timing line + static ref RUN_TIME_RE: Regex = Regex::new( + r"Run completed in (\d+) seconds?" + ).unwrap(); + + /// Matches [info] Compiling N Scala source(s) + static ref COMPILE_COUNT_RE: Regex = Regex::new( + r"\[info\] Compiling (\d+) Scala source" + ).unwrap(); + + /// Matches [success] Total time: Ns + static ref SUCCESS_TIME_RE: Regex = Regex::new( + r"\[success\] Total time: (\d+) s" + ).unwrap(); + + /// Matches [error] lines + static ref ERROR_RE: Regex = Regex::new( + r"^\[error\]" + ).unwrap(); + + /// Lines that are SBT noise (loading, resolving, downloading, etc.) + static ref NOISE_RE: Regex = Regex::new( + r"^\[info\] (welcome to sbt|loading |set current project|Updating |Resolved |Fetching |downloading |Done )" + ).unwrap(); +} + +pub fn run_test(args: &[String], verbose: u8) -> Result<()> { + let timer = tracking::TimedExecution::start(); + + let mut cmd = resolved_command("sbt"); + cmd.arg("test"); + + for arg in args { + cmd.arg(arg); + } + + if verbose > 0 { + eprintln!("Running: sbt test {}", args.join(" ")); + } + + let output = cmd + .output() + .context("Failed to run sbt test. Is SBT installed?")?; + + 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_sbt_test(&raw); + + if let Some(hint) = crate::core::tee::tee_and_hint(&raw, "sbt_test", exit_code) { + println!("{}\n{}", filtered, hint); + } else { + println!("{}", filtered); + } + + timer.track( + &format!("sbt test {}", args.join(" ")), + &format!("rtk sbt test {}", args.join(" ")), + &raw, + &filtered, + ); + + if !output.status.success() { + std::process::exit(exit_code); + } + + Ok(()) +} + +pub fn run_compile(args: &[String], verbose: u8) -> Result<()> { + let timer = tracking::TimedExecution::start(); + + let mut cmd = resolved_command("sbt"); + cmd.arg("compile"); + + for arg in args { + cmd.arg(arg); + } + + if verbose > 0 { + eprintln!("Running: sbt compile {}", args.join(" ")); + } + + let output = cmd + .output() + .context("Failed to run sbt compile. Is SBT installed?")?; + + 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_sbt_compile(&raw); + + if let Some(hint) = crate::core::tee::tee_and_hint(&raw, "sbt_compile", exit_code) { + if !filtered.is_empty() { + println!("{}\n{}", filtered, hint); + } else { + println!("{}", hint); + } + } else if !filtered.is_empty() { + println!("{}", filtered); + } + + timer.track( + &format!("sbt compile {}", args.join(" ")), + &format!("rtk sbt compile {}", args.join(" ")), + &raw, + &filtered, + ); + + if !output.status.success() { + std::process::exit(exit_code); + } + + Ok(()) +} + +pub fn run_run(args: &[String], verbose: u8) -> Result<()> { + let timer = tracking::TimedExecution::start(); + + let mut cmd = resolved_command("sbt"); + cmd.arg("run"); + + for arg in args { + cmd.arg(arg); + } + + if verbose > 0 { + eprintln!("Running: sbt run {}", args.join(" ")); + } + + let output = cmd + .output() + .context("Failed to run sbt run. Is SBT installed?")?; + + 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_sbt_run(&raw); + + if let Some(hint) = crate::core::tee::tee_and_hint(&raw, "sbt_run", exit_code) { + println!("{}\n{}", filtered, hint); + } else { + println!("{}", filtered); + } + + timer.track( + &format!("sbt run {}", args.join(" ")), + &format!("rtk sbt run {}", args.join(" ")), + &raw, + &filtered, + ); + + if !output.status.success() { + std::process::exit(exit_code); + } + + Ok(()) +} + +pub fn run_other(args: &[OsString], verbose: u8) -> Result<()> { + if args.is_empty() { + anyhow::bail!("sbt: no subcommand specified"); + } + + let timer = tracking::TimedExecution::start(); + + let subcommand = args[0].to_string_lossy(); + let mut cmd = resolved_command("sbt"); + cmd.arg(&*subcommand); + + for arg in &args[1..] { + cmd.arg(arg); + } + + if verbose > 0 { + eprintln!("Running: sbt {} ...", subcommand); + } + + let output = cmd + .output() + .with_context(|| format!("Failed to run sbt {}", 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!("sbt {}", subcommand), + &format!("rtk sbt {}", subcommand), + &raw, + &raw, + ); + + if !output.status.success() { + std::process::exit(output.status.code().unwrap_or(1)); + } + + Ok(()) +} + +/// Filter SBT test output (ScalaTest format). +/// +/// On success: compact single-line summary. +/// On failure: show failed test details + summary. +fn filter_sbt_test(output: &str) -> String { + let mut succeeded: u32 = 0; + let mut failed: u32 = 0; + let mut ignored: u32 = 0; + let mut canceled: u32 = 0; + let mut pending: u32 = 0; + let mut suites: u32 = 0; + let mut run_time_secs: Option = None; + let mut has_summary = false; + + // Collect failure details + let mut failure_lines: Vec = Vec::new(); + let mut error_lines: Vec = Vec::new(); + + for line in output.lines() { + let trimmed = line.trim(); + + // Parse the ScalaTest summary line + if let Some(caps) = TEST_SUMMARY_RE.captures(trimmed) { + succeeded = caps[1].parse().unwrap_or(0); + failed = caps[2].parse().unwrap_or(0); + canceled = caps[3].parse().unwrap_or(0); + ignored = caps[4].parse().unwrap_or(0); + pending = caps[5].parse().unwrap_or(0); + has_summary = true; + continue; + } + + // Parse suite count + if let Some(caps) = SUITE_SUMMARY_RE.captures(trimmed) { + suites = caps[1].parse().unwrap_or(0); + continue; + } + + // Parse run time + if let Some(caps) = RUN_TIME_RE.captures(trimmed) { + run_time_secs = caps[1].parse().ok(); + continue; + } + + // Collect failed test lines (*** FAILED ***) + if trimmed.contains("*** FAILED ***") { + failure_lines.push(trimmed.to_string()); + continue; + } + + // Collect [error] lines + if ERROR_RE.is_match(trimmed) { + let error_text = trimmed.strip_prefix("[error] ").unwrap_or(trimmed); + if !error_text.is_empty() { + error_lines.push(error_text.to_string()); + } + } + } + + // If no summary found, return a minimal fallback + if !has_summary { + if !error_lines.is_empty() { + let mut result = String::from("sbt test: parse error\n"); + result.push_str("═══════════════════════════════════════\n"); + for line in error_lines.iter().take(20) { + result.push_str(&format!(" {}\n", truncate(line, 120))); + } + return result.trim().to_string(); + } + if output.trim().is_empty() { + return "sbt test: No test output".to_string(); + } + return output.to_string(); + } + + let time_str = run_time_secs.map(|s| format!("{}s", s)).unwrap_or_default(); + + // All passed + if failed == 0 && canceled == 0 { + let mut summary = format!("sbt test: {} passed", succeeded); + if ignored > 0 { + summary.push_str(&format!(", {} ignored", ignored)); + } + if pending > 0 { + summary.push_str(&format!(", {} pending", pending)); + } + if suites > 0 { + summary.push_str(&format!(" ({} suites", suites)); + } + if !time_str.is_empty() { + if suites > 0 { + summary.push_str(&format!(", {}", time_str)); + } else { + summary.push_str(&format!(" ({})", time_str)); + } + } + if suites > 0 { + summary.push(')'); + } + return summary; + } + + // Failures present + let mut result = format!("sbt test: {} passed, {} failed", succeeded, failed); + if ignored > 0 { + result.push_str(&format!(", {} ignored", ignored)); + } + if !time_str.is_empty() { + result.push_str(&format!(" ({})", time_str)); + } + result.push('\n'); + result.push_str("═══════════════════════════════════════\n"); + + // Show failure details + for line in &failure_lines { + result.push_str(&format!(" [FAIL] {}\n", truncate(line, 120))); + } + + // Show error lines (failed suites, etc.) + if !error_lines.is_empty() { + result.push('\n'); + for line in error_lines.iter().take(20) { + result.push_str(&format!(" {}\n", truncate(line, 120))); + } + } + + result.trim().to_string() +} + +/// Filter SBT compile output. +/// +/// On success: compact summary with source count and time. +/// On failure: show all [error] lines. +fn filter_sbt_compile(output: &str) -> String { + let mut source_count: u32 = 0; + let mut total_time_secs: Option = None; + let mut error_lines: Vec = Vec::new(); + let mut has_success = false; + + for line in output.lines() { + let trimmed = line.trim(); + + // Count compiled sources + if let Some(caps) = COMPILE_COUNT_RE.captures(trimmed) { + source_count += caps[1].parse::().unwrap_or(0); + continue; + } + + // Parse success time + if let Some(caps) = SUCCESS_TIME_RE.captures(trimmed) { + total_time_secs = caps[1].parse().ok(); + has_success = true; + continue; + } + + // Collect [error] lines + if ERROR_RE.is_match(trimmed) { + let error_text = trimmed.strip_prefix("[error] ").unwrap_or(trimmed); + if !error_text.is_empty() { + error_lines.push(error_text.to_string()); + } + } + } + + // Compilation errors + if !error_lines.is_empty() { + let mut result = format!("sbt compile: {} errors\n", error_lines.len()); + result.push_str("═══════════════════════════════════════\n"); + + for (i, error) in error_lines.iter().take(30).enumerate() { + result.push_str(&format!("{}. {}\n", i + 1, truncate(error, 120))); + } + + if error_lines.len() > 30 { + result.push_str(&format!("\n... +{} more errors\n", error_lines.len() - 30)); + } + + return result.trim().to_string(); + } + + // Success + if has_success || source_count > 0 { + let mut summary = String::from("sbt compile: "); + if source_count > 0 { + summary.push_str(&format!("{} sources", source_count)); + } else { + summary.push_str("Success"); + } + if let Some(secs) = total_time_secs { + summary.push_str(&format!(" ({}s)", secs)); + } + return summary; + } + + // Fallback: nothing recognized + "sbt compile: Success".to_string() +} + +/// Filter SBT run output — light filtering. +/// +/// Strips SBT preamble noise, keeps actual program output. +fn filter_sbt_run(output: &str) -> String { + let mut result_lines: Vec<&str> = Vec::new(); + + for line in output.lines() { + let trimmed = line.trim(); + + // Skip empty lines at the start + if trimmed.is_empty() && result_lines.is_empty() { + continue; + } + + // Skip SBT noise lines + if NOISE_RE.is_match(trimmed) { + continue; + } + + // Skip [info] Compiling lines + if COMPILE_COUNT_RE.is_match(trimmed) { + continue; + } + + // Skip [info] running ... preamble + if trimmed.starts_with("[info] running ") || trimmed.starts_with("[info] Running ") { + continue; + } + + // Strip [info] prefix from program output + if let Some(content) = trimmed.strip_prefix("[info] ") { + result_lines.push(content); + } else if let Some(content) = trimmed.strip_prefix("[success] ") { + // Skip success time line + if content.starts_with("Total time:") { + continue; + } + result_lines.push(content); + } else if ERROR_RE.is_match(trimmed) { + let error_text = trimmed.strip_prefix("[error] ").unwrap_or(trimmed); + result_lines.push(error_text); + } else { + result_lines.push(trimmed); + } + } + + result_lines.join("\n").trim().to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn count_tokens(text: &str) -> usize { + text.split_whitespace().count() + } + + #[test] + fn test_filter_sbt_test_all_pass() { + let input = include_str!("../tests/fixtures/sbt/sbt_test_pass.txt"); + let output = filter_sbt_test(input); + + assert!(output.starts_with("sbt test:")); + assert!(output.contains("30 passed")); + assert!(output.contains("2 ignored")); + assert!(output.contains("5 suites")); + assert!(output.contains("5s")); + // Should be a compact single line + assert!(!output.contains('\n'), "All-pass output should be one line"); + } + + #[test] + fn test_filter_sbt_test_with_failures() { + let input = include_str!("../tests/fixtures/sbt/sbt_test_fail.txt"); + let output = filter_sbt_test(input); + + assert!(output.contains("15 passed")); + assert!(output.contains("3 failed")); + assert!(output.contains("FAIL")); + assert!(output.contains("FAILED")); + } + + #[test] + fn test_filter_sbt_test_token_savings() { + let input = include_str!("../tests/fixtures/sbt/sbt_test_pass.txt"); + let output = filter_sbt_test(input); + + let input_tokens = count_tokens(input); + let output_tokens = count_tokens(&output); + + let savings = 100.0 - (output_tokens as f64 / input_tokens as f64 * 100.0); + assert!( + savings >= 60.0, + "sbt test (pass) filter: expected >=60% savings, got {:.1}% (input: {}, output: {})", + savings, + input_tokens, + output_tokens + ); + } + + #[test] + fn test_filter_sbt_test_fail_token_savings() { + let input = include_str!("../tests/fixtures/sbt/sbt_test_fail.txt"); + let output = filter_sbt_test(input); + + let input_tokens = count_tokens(input); + let output_tokens = count_tokens(&output); + + let savings = 100.0 - (output_tokens as f64 / input_tokens as f64 * 100.0); + assert!( + savings >= 40.0, + "sbt test (fail) filter: expected >=40% savings, got {:.1}% (input: {}, output: {})", + savings, + input_tokens, + output_tokens + ); + } + + #[test] + fn test_filter_sbt_compile_success() { + let input = "[info] loading settings for project root from build.sbt ...\n\ + [info] Compiling 15 Scala sources to /target/scala-2.13/classes ...\n\ + [success] Total time: 12 s, completed Jan 15, 2025"; + let output = filter_sbt_compile(input); + + assert!(output.contains("sbt compile:")); + assert!(output.contains("15 sources")); + assert!(output.contains("12s")); + } + + #[test] + fn test_filter_sbt_compile_errors() { + let input = include_str!("../tests/fixtures/sbt/sbt_compile_error.txt"); + let output = filter_sbt_compile(input); + + assert!(output.contains("sbt compile:")); + assert!(output.contains("errors")); + assert!(output.contains("type mismatch")); + assert!(output.contains("not found: value")); + } + + #[test] + fn test_filter_sbt_compile_error_token_savings() { + let input = include_str!("../tests/fixtures/sbt/sbt_compile_error.txt"); + let output = filter_sbt_compile(input); + + let input_tokens = count_tokens(input); + let output_tokens = count_tokens(&output); + + let savings = 100.0 - (output_tokens as f64 / input_tokens as f64 * 100.0); + assert!( + savings >= 30.0, + "sbt compile (error) filter: expected >=30% savings, got {:.1}% (input: {}, output: {})", + savings, + input_tokens, + output_tokens + ); + } + + #[test] + fn test_filter_sbt_run_strips_noise() { + let input = "[info] welcome to sbt 1.9.7\n\ + [info] loading settings for project root from build.sbt ...\n\ + [info] set current project to myapp\n\ + [info] running com.example.Main\n\ + [info] Hello, World!\n\ + [info] Server started on port 8080\n\ + [success] Total time: 3 s, completed Jan 15, 2025"; + let output = filter_sbt_run(input); + + assert!(output.contains("Hello, World!")); + assert!(output.contains("Server started on port 8080")); + assert!(!output.contains("welcome to sbt")); + assert!(!output.contains("loading settings")); + assert!(!output.contains("set current project")); + assert!(!output.contains("running com.example")); + assert!(!output.contains("Total time:")); + } + + #[test] + fn test_filter_sbt_test_empty_input() { + let output = filter_sbt_test(""); + assert!(!output.is_empty()); + } + + #[test] + fn test_filter_sbt_compile_empty_input() { + let output = filter_sbt_compile(""); + assert!(output.contains("sbt compile:")); + assert!(output.contains("Success")); + } + + #[test] + fn test_filter_sbt_run_empty_input() { + let output = filter_sbt_run(""); + // Empty input produces empty output + assert!(output.is_empty()); + } +} diff --git a/src/discover/rules.rs b/src/discover/rules.rs index df7c72d03..21c10e46f 100644 --- a/src/discover/rules.rs +++ b/src/discover/rules.rs @@ -479,6 +479,16 @@ pub const RULES: &[RtkRule] = &[ subcmd_savings: &[], subcmd_status: &[], }, + // Scala/SBT + RtkRule { + pattern: r"^sbt\s+(test|compile|run|clean|assembly|package)", + rtk_cmd: "rtk sbt", + rewrite_prefixes: &["sbt"], + category: "Build", + savings_pct: 80.0, + subcmd_savings: &[("test", 90.0), ("compile", 75.0)], + subcmd_status: &[], + }, RtkRule { pattern: r"^bundle\s+(install|update)\b", rtk_cmd: "rtk bundle", diff --git a/src/main.rs b/src/main.rs index 22e6cbca8..ee4e2610e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,6 +19,7 @@ use cmds::jvm::gradlew_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}; +use cmds::scala::sbt_cmd; use cmds::system::{ deps, env_cmd, find_cmd, format_cmd, grep_cmd, json_cmd, local_llm, log_cmd, ls, pipe_cmd, read, summary, tree, wc_cmd, @@ -714,6 +715,12 @@ enum Commands { command: GoCommands, }, + /// SBT (Scala Build Tool) commands with compact output + Sbt { + #[command(subcommand)] + command: SbtCommands, + }, + /// Graphite (gt) stacked PR commands with compact output Gt { #[command(subcommand)] @@ -1123,6 +1130,31 @@ enum GoCommands { Other(Vec), } +#[derive(Subcommand)] +enum SbtCommands { + /// Run tests with compact output (90% token reduction via ScalaTest filtering) + Test { + /// Additional sbt test arguments + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Compile with compact output (errors only) + Compile { + /// Additional sbt compile arguments + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Run application with noise-stripped output + Run { + /// Additional sbt run arguments + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Passthrough: runs any unsupported sbt subcommand directly + #[command(external_subcommand)] + Other(Vec), +} + /// RTK-only subcommands that should never fall back to raw execution. /// If Clap fails to parse these, show the Clap error directly. const RTK_META_COMMANDS: &[&str] = &[ @@ -2155,6 +2187,16 @@ fn run_cli() -> Result { GoCommands::Other(args) => go_cmd::run_other(&args, cli.verbose)?, }, + Commands::Sbt { command } => { + match command { + SbtCommands::Test { args } => sbt_cmd::run_test(&args, cli.verbose)?, + SbtCommands::Compile { args } => sbt_cmd::run_compile(&args, cli.verbose)?, + SbtCommands::Run { args } => sbt_cmd::run_run(&args, cli.verbose)?, + SbtCommands::Other(args) => sbt_cmd::run_other(&args, cli.verbose)?, + } + 0 + } + Commands::Gt { command } => match command { GtCommands::Log { args } => gt_cmd::run_log(&args, cli.verbose)?, GtCommands::Submit { args } => gt_cmd::run_submit(&args, cli.verbose)?, @@ -2507,6 +2549,7 @@ fn is_operational_command(cmd: &Commands) -> bool { | Commands::Rspec { .. } | Commands::Pip { .. } | Commands::Go { .. } + | Commands::Sbt { .. } | Commands::GolangciLint { .. } | Commands::Gt { .. } ) diff --git a/tests/fixtures/sbt/sbt_compile_error.txt b/tests/fixtures/sbt/sbt_compile_error.txt new file mode 100644 index 000000000..50156688f --- /dev/null +++ b/tests/fixtures/sbt/sbt_compile_error.txt @@ -0,0 +1,20 @@ +[info] welcome to sbt 1.9.7 (Eclipse Adoptium Java 17.0.9) +[info] loading settings for project root-build from plugins.sbt ... +[info] loading project definition from /home/user/myproject/project +[info] loading settings for project root from build.sbt ... +[info] set current project to myproject (in build file:/home/user/myproject/) +[info] Compiling 3 Scala sources to /home/user/myproject/target/scala-2.13/classes ... +[error] /home/user/myproject/src/main/scala/com/example/MyService.scala:42:5: type mismatch; +[error] found : String +[error] required: Int +[error] val count: Int = "hello" +[error] ^ +[error] /home/user/myproject/src/main/scala/com/example/MyService.scala:58:12: not found: value undeclaredVar +[error] println(undeclaredVar) +[error] ^ +[error] /home/user/myproject/src/main/scala/com/example/MyRepository.scala:23:8: value nonExistentMethod is not a member of com.example.Database +[error] db.nonExistentMethod() +[error] ^ +[error] three errors found +[error] (Compile / compileIncremental) Compilation failed +[error] Total time: 8 s, completed Jan 15, 2025 10:29:15 AM diff --git a/tests/fixtures/sbt/sbt_test_fail.txt b/tests/fixtures/sbt/sbt_test_fail.txt new file mode 100644 index 000000000..d57940612 --- /dev/null +++ b/tests/fixtures/sbt/sbt_test_fail.txt @@ -0,0 +1,47 @@ +[info] welcome to sbt 1.9.7 (Eclipse Adoptium Java 17.0.9) +[info] loading settings for project root-build from plugins.sbt ... +[info] loading project definition from /home/user/myproject/project +[info] loading settings for project root from build.sbt ... +[info] set current project to myproject (in build file:/home/user/myproject/) +[info] Compiling 3 Scala sources to /home/user/myproject/target/scala-2.13/test-classes ... +[info] MyServiceSpec: +[info] A MyService +[info] when initialized +[info] - should return default config +[info] - should accept valid parameters +[info] - should reject invalid parameters *** FAILED *** +[info] Expected ServiceException to be thrown, but no exception was thrown (MyServiceSpec.scala:45) +[info] when running +[info] - should process events correctly +[info] - should handle timeouts gracefully *** FAILED *** +[info] The future timed out after [5 seconds]. (MyServiceSpec.scala:78) +[info] - should retry on transient failures +[info] MyRepositorySpec: +[info] A MyRepository +[info] - should save entities +[info] - should find entities by id +[info] - should delete entities +[info] - should list all entities +[info] - should handle concurrent writes *** FAILED *** +[info] 42 was not equal to 43 (MyRepositorySpec.scala:92) +[info] MyControllerSpec: +[info] A MyController +[info] GET /api/items +[info] - should return 200 with items +[info] - should return 404 when not found +[info] POST /api/items +[info] - should return 201 on create +[info] - should return 400 on invalid input +[info] DELETE /api/items/:id +[info] - should return 204 on delete +[info] - should return 404 when not found +[info] Run completed in 8 seconds, 442 milliseconds. +[info] Total number of tests run: 18 +[info] Suites: completed 3, aborted 0 +[info] Tests: succeeded 15, failed 3, canceled 0, ignored 0, pending 0 +[info] *** 3 TESTS FAILED *** +[error] Failed tests: +[error] com.example.MyServiceSpec +[error] com.example.MyRepositorySpec +[error] (Test / test) sbt.TestsFailedException: Tests unsuccessful +[error] Total time: 15 s, completed Jan 15, 2025 10:31:02 AM diff --git a/tests/fixtures/sbt/sbt_test_pass.txt b/tests/fixtures/sbt/sbt_test_pass.txt new file mode 100644 index 000000000..dec282a7d --- /dev/null +++ b/tests/fixtures/sbt/sbt_test_pass.txt @@ -0,0 +1,58 @@ +[info] welcome to sbt 1.9.7 (Eclipse Adoptium Java 17.0.9) +[info] loading settings for project root-build from plugins.sbt ... +[info] loading project definition from /home/user/myproject/project +[info] loading settings for project root from build.sbt ... +[info] set current project to myproject (in build file:/home/user/myproject/) +[info] Compiling 15 Scala sources to /home/user/myproject/target/scala-2.13/classes ... +[info] Compiling 8 Scala sources to /home/user/myproject/target/scala-2.13/test-classes ... +[info] MyServiceSpec: +[info] A MyService +[info] when initialized +[info] - should return default config +[info] - should accept valid parameters +[info] - should reject invalid parameters +[info] when running +[info] - should process events correctly +[info] - should handle timeouts gracefully +[info] - should retry on transient failures +[info] MyRepositorySpec: +[info] A MyRepository +[info] - should save entities +[info] - should find entities by id +[info] - should delete entities +[info] - should list all entities +[info] - should handle concurrent writes +[info] MyControllerSpec: +[info] A MyController +[info] GET /api/items +[info] - should return 200 with items +[info] - should return 404 when not found +[info] POST /api/items +[info] - should return 201 on create +[info] - should return 400 on invalid input +[info] DELETE /api/items/:id +[info] - should return 204 on delete +[info] - should return 404 when not found +[info] UtilsSpec: +[info] Utils +[info] - should parse ISO dates +[info] - should format currency values +[info] - should validate email addresses +[info] - should sanitize HTML input +[info] - should truncate long strings +[info] IntegrationSpec: +[info] Integration tests +[info] database operations +[info] - should connect to test database +[info] - should run migrations +[info] - should rollback on failure +[info] API endpoints +[info] - should health check pass +[info] - should authenticate users +[info] - should authorize requests +[info] Run completed in 5 seconds, 218 milliseconds. +[info] Total number of tests run: 30 +[info] Suites: completed 5, aborted 0 +[info] Tests: succeeded 30, failed 0, canceled 0, ignored 2, pending 0 +[info] All tests passed. +[success] Total time: 12 s, completed Jan 15, 2025 10:30:45 AM From d07857886f63d286ce144de2cef549571ee4ad98 Mon Sep 17 00:00:00 2001 From: Ivan Severino <4858703+randomBrainstormer@users.noreply.github.com> Date: Mon, 6 Apr 2026 09:10:51 +0200 Subject: [PATCH 003/154] feat(sbt): capture failure details, add integration test routing, handle Mockito/ScalaMock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - filter_sbt_test now uses a state machine to capture [info] detail lines that follow a *** FAILED *** marker — covers native ScalaTest assertion messages, Mockito Scala verification failures (WantedButNotInvoked, TooManyActualInvocations), and ScalaMock expectation failures (Unexpected call, Unsatisfied expectation) - run_other detects integration test commands (it:test, IntegrationTest/test, integration-test/test, and any *:test / */test variant) and applies filter_sbt_test instead of raw passthrough - sbt boilerplate cleaned from failure output: TestsFailedException, Total time, and compileIncremental noise removed; failed suite class names retained for navigation - add fixtures: sbt_test_mockito_fail.txt, sbt_test_scalamock_fail.txt, sbt_it_test_pass.txt - 18 tests (was 11), all passing Co-Authored-By: Claude Sonnet 4.6 --- src/cmds/scala/sbt_cmd.rs | 406 ++++++++++++++---- tests/fixtures/sbt/sbt_it_test_pass.txt | 21 + tests/fixtures/sbt/sbt_test_mockito_fail.txt | 33 ++ .../fixtures/sbt/sbt_test_scalamock_fail.txt | 31 ++ 4 files changed, 404 insertions(+), 87 deletions(-) create mode 100644 tests/fixtures/sbt/sbt_it_test_pass.txt create mode 100644 tests/fixtures/sbt/sbt_test_mockito_fail.txt create mode 100644 tests/fixtures/sbt/sbt_test_scalamock_fail.txt diff --git a/src/cmds/scala/sbt_cmd.rs b/src/cmds/scala/sbt_cmd.rs index 0b20d4b4e..c6fd289c6 100644 --- a/src/cmds/scala/sbt_cmd.rs +++ b/src/cmds/scala/sbt_cmd.rs @@ -44,6 +44,15 @@ lazy_static! { ).unwrap(); } +/// Integration test subcommand patterns (sbt configuration/task notation). +/// These produce ScalaTest output and should use the same filtering as `sbt test`. +fn is_integration_test_cmd(subcommand: &str) -> bool { + matches!( + subcommand, + "it:test" | "IntegrationTest/test" | "integration-test/test" + ) || (subcommand.ends_with(":test") || subcommand.ends_with("/test")) +} + pub fn run_test(args: &[String], verbose: u8) -> Result<()> { let timer = tracking::TimedExecution::start(); @@ -219,27 +228,59 @@ pub fn run_other(args: &[OsString], verbose: u8) -> Result<()> { let stderr = String::from_utf8_lossy(&output.stderr); let raw = format!("{}\n{}", stdout, stderr); - print!("{}", stdout); - eprint!("{}", stderr); + let exit_code = output + .status + .code() + .unwrap_or(if output.status.success() { 0 } else { 1 }); - timer.track( - &format!("sbt {}", subcommand), - &format!("rtk sbt {}", subcommand), - &raw, - &raw, - ); + // Integration test commands (it:test, IntegrationTest/test, etc.) produce + // standard ScalaTest output — apply the same filtering as `sbt test`. + if is_integration_test_cmd(&subcommand) { + let filtered = filter_sbt_test(&raw); + + if let Some(hint) = crate::core::tee::tee_and_hint(&raw, "sbt_it_test", exit_code) { + println!("{}\n{}", filtered, hint); + } else { + println!("{}", filtered); + } + + timer.track( + &format!("sbt {}", subcommand), + &format!("rtk sbt {}", subcommand), + &raw, + &filtered, + ); + } else { + print!("{}", stdout); + eprint!("{}", stderr); + + timer.track( + &format!("sbt {}", subcommand), + &format!("rtk sbt {}", subcommand), + &raw, + &raw, + ); + } if !output.status.success() { - std::process::exit(output.status.code().unwrap_or(1)); + std::process::exit(exit_code); } Ok(()) } +/// A single test failure with its name and detail lines captured from the output. +struct FailureBlock { + name: String, + details: Vec, +} + /// Filter SBT test output (ScalaTest format). /// /// On success: compact single-line summary. -/// On failure: show failed test details + summary. +/// On failure: show each failed test with its detail lines (works for native +/// ScalaTest assertion failures, Mockito Scala verification failures, and +/// ScalaMock expectation failures — all of which emit details as [info] lines). fn filter_sbt_test(output: &str) -> String { let mut succeeded: u32 = 0; let mut failed: u32 = 0; @@ -250,14 +291,17 @@ fn filter_sbt_test(output: &str) -> String { let mut run_time_secs: Option = None; let mut has_summary = false; - // Collect failure details - let mut failure_lines: Vec = Vec::new(); + let mut failures: Vec = Vec::new(); + let mut failed_suites: Vec = Vec::new(); let mut error_lines: Vec = Vec::new(); + // true while we are inside the detail block of a failed test + let mut in_failure_detail = false; for line in output.lines() { let trimmed = line.trim(); - // Parse the ScalaTest summary line + // --- Summary lines (always reset failure-detail mode) --- + if let Some(caps) = TEST_SUMMARY_RE.captures(trimmed) { succeeded = caps[1].parse().unwrap_or(0); failed = caps[2].parse().unwrap_or(0); @@ -265,37 +309,101 @@ fn filter_sbt_test(output: &str) -> String { ignored = caps[4].parse().unwrap_or(0); pending = caps[5].parse().unwrap_or(0); has_summary = true; + in_failure_detail = false; continue; } - - // Parse suite count if let Some(caps) = SUITE_SUMMARY_RE.captures(trimmed) { suites = caps[1].parse().unwrap_or(0); + in_failure_detail = false; continue; } - - // Parse run time if let Some(caps) = RUN_TIME_RE.captures(trimmed) { run_time_secs = caps[1].parse().ok(); + in_failure_detail = false; continue; } - // Collect failed test lines (*** FAILED ***) + // --- Failed test header: "- test name *** FAILED ***" --- + if trimmed.contains("*** FAILED ***") { - failure_lines.push(trimmed.to_string()); + let name = trimmed + .strip_suffix(" *** FAILED ***") + .unwrap_or(trimmed) + .strip_prefix("[info]") + .unwrap_or(trimmed) + .trim() + .trim_start_matches('-') + .trim() + .to_string(); + failures.push(FailureBlock { name, details: Vec::new() }); + in_failure_detail = true; continue; } - // Collect [error] lines + // --- Detail lines inside a failure block --- + // + // ScalaTest places failure details as [info] lines with deeper + // indentation (4+ spaces after "[info]"). This covers: + // - native assertion messages ("42 was not equal to 43") + // - Mockito verification msgs ("org.mockito.exceptions.verification...") + // - ScalaMock expectation msgs ("Unexpected call: ...") + // + // A line with shallower indentation (new test case or section header) + // signals the end of the detail block. + + if in_failure_detail { + if let Some(after_info) = trimmed.strip_prefix("[info]") { + if after_info.starts_with(" ") { + let detail = after_info.trim(); + if !detail.is_empty() { + // Skip raw JVM stack frames — they add noise without signal. + // Keep Mockito "-> at" pointers and ScalaMock locations + // (they include the file:line reference). + let is_stack_frame = detail.starts_with("at ") + || detail.starts_with("..."); + if !is_stack_frame { + if let Some(block) = failures.last_mut() { + if block.details.len() < 4 { + block.details.push(detail.to_string()); + } + } + } + } + continue; + } else { + // Shallower indentation → back to normal test output + in_failure_detail = false; + } + } else { + in_failure_detail = false; + } + } + + // --- [error] lines: collect failed suite names, drop sbt boilerplate --- + if ERROR_RE.is_match(trimmed) { - let error_text = trimmed.strip_prefix("[error] ").unwrap_or(trimmed); - if !error_text.is_empty() { - error_lines.push(error_text.to_string()); + let text = trimmed.strip_prefix("[error] ").unwrap_or(trimmed).trim(); + if text.is_empty() + || text.starts_with("Total time:") + || text.contains("TestsFailedException") + || text.contains("compileIncremental") + { + continue; + } + // "Failed tests:" header + class names → collect separately + if text == "Failed tests:" { + continue; // the header is implicit from context + } + if text.starts_with("com.") || text.starts_with("org.") || text.starts_with(" ") { + failed_suites.push(text.trim_start().to_string()); + } else { + error_lines.push(text.to_string()); } } } - // If no summary found, return a minimal fallback + // --- Fallback: no summary line found --- + if !has_summary { if !error_lines.is_empty() { let mut result = String::from("sbt test: parse error\n"); @@ -313,7 +421,8 @@ fn filter_sbt_test(output: &str) -> String { let time_str = run_time_secs.map(|s| format!("{}s", s)).unwrap_or_default(); - // All passed + // --- All passed --- + if failed == 0 && canceled == 0 { let mut summary = format!("sbt test: {} passed", succeeded); if ignored > 0 { @@ -324,22 +433,22 @@ fn filter_sbt_test(output: &str) -> String { } if suites > 0 { summary.push_str(&format!(" ({} suites", suites)); - } - if !time_str.is_empty() { - if suites > 0 { + if !time_str.is_empty() { summary.push_str(&format!(", {}", time_str)); - } else { - summary.push_str(&format!(" ({})", time_str)); } - } - if suites > 0 { summary.push(')'); + } else if !time_str.is_empty() { + summary.push_str(&format!(" ({})", time_str)); } return summary; } - // Failures present + // --- Failures present --- + let mut result = format!("sbt test: {} passed, {} failed", succeeded, failed); + if canceled > 0 { + result.push_str(&format!(", {} canceled", canceled)); + } if ignored > 0 { result.push_str(&format!(", {} ignored", ignored)); } @@ -349,15 +458,25 @@ fn filter_sbt_test(output: &str) -> String { result.push('\n'); result.push_str("═══════════════════════════════════════\n"); - // Show failure details - for line in &failure_lines { - result.push_str(&format!(" [FAIL] {}\n", truncate(line, 120))); + for block in &failures { + result.push_str(&format!(" [FAIL] {}\n", truncate(&block.name, 120))); + for detail in &block.details { + result.push_str(&format!(" {}\n", truncate(detail, 120))); + } + } + + // Failed suite class names (useful for navigation) + if !failed_suites.is_empty() { + result.push('\n'); + for suite in &failed_suites { + result.push_str(&format!(" {}\n", suite)); + } } - // Show error lines (failed suites, etc.) + // Any remaining [error] lines (e.g. build-level errors) if !error_lines.is_empty() { result.push('\n'); - for line in error_lines.iter().take(20) { + for line in error_lines.iter().take(10) { result.push_str(&format!(" {}\n", truncate(line, 120))); } } @@ -491,72 +610,188 @@ mod tests { text.split_whitespace().count() } + // --- sbt test: all-pass --- + #[test] fn test_filter_sbt_test_all_pass() { - let input = include_str!("../tests/fixtures/sbt/sbt_test_pass.txt"); + let input = include_str!("../../../tests/fixtures/sbt/sbt_test_pass.txt"); let output = filter_sbt_test(input); - assert!(output.starts_with("sbt test:")); + assert!(output.starts_with("sbt test:"), "output: {}", output); assert!(output.contains("30 passed")); assert!(output.contains("2 ignored")); assert!(output.contains("5 suites")); assert!(output.contains("5s")); - // Should be a compact single line - assert!(!output.contains('\n'), "All-pass output should be one line"); + assert!(!output.contains('\n'), "all-pass output should be a single line"); } + #[test] + fn test_filter_sbt_test_all_pass_token_savings() { + let input = include_str!("../../../tests/fixtures/sbt/sbt_test_pass.txt"); + let output = filter_sbt_test(input); + let savings = 100.0 + - (count_tokens(&output) as f64 / count_tokens(input) as f64 * 100.0); + assert!( + savings >= 60.0, + "sbt test (pass): expected >=60% savings, got {:.1}%", + savings + ); + } + + // --- sbt test: ScalaTest failures --- + #[test] fn test_filter_sbt_test_with_failures() { - let input = include_str!("../tests/fixtures/sbt/sbt_test_fail.txt"); + let input = include_str!("../../../tests/fixtures/sbt/sbt_test_fail.txt"); let output = filter_sbt_test(input); - assert!(output.contains("15 passed")); + assert!(output.contains("15 passed"), "output: {}", output); assert!(output.contains("3 failed")); - assert!(output.contains("FAIL")); - assert!(output.contains("FAILED")); + assert!(output.contains("[FAIL]")); + // Detail lines from the fixture should appear + assert!( + output.contains("Expected ServiceException"), + "missing failure detail: {}", + output + ); + assert!(output.contains("MyServiceSpec.scala:45")); + assert!(output.contains("timed out")); + assert!(output.contains("42 was not equal to 43")); } #[test] - fn test_filter_sbt_test_token_savings() { - let input = include_str!("../tests/fixtures/sbt/sbt_test_pass.txt"); + fn test_filter_sbt_test_failures_no_noise() { + let input = include_str!("../../../tests/fixtures/sbt/sbt_test_fail.txt"); let output = filter_sbt_test(input); - let input_tokens = count_tokens(input); - let output_tokens = count_tokens(&output); + // SBT boilerplate must be stripped + assert!(!output.contains("welcome to sbt")); + assert!(!output.contains("loading settings")); + assert!(!output.contains("TestsFailedException")); + assert!(!output.contains("Total time:")); + } - let savings = 100.0 - (output_tokens as f64 / input_tokens as f64 * 100.0); + #[test] + fn test_filter_sbt_test_fail_token_savings() { + let input = include_str!("../../../tests/fixtures/sbt/sbt_test_fail.txt"); + let output = filter_sbt_test(input); + let savings = 100.0 + - (count_tokens(&output) as f64 / count_tokens(input) as f64 * 100.0); assert!( - savings >= 60.0, - "sbt test (pass) filter: expected >=60% savings, got {:.1}% (input: {}, output: {})", - savings, - input_tokens, - output_tokens + savings >= 40.0, + "sbt test (fail): expected >=40% savings, got {:.1}%", + savings ); } + // --- sbt test: Mockito Scala verification failures --- + #[test] - fn test_filter_sbt_test_fail_token_savings() { - let input = include_str!("../tests/fixtures/sbt/sbt_test_fail.txt"); + fn test_filter_sbt_test_mockito_failure_details() { + let input = include_str!("../../../tests/fixtures/sbt/sbt_test_mockito_fail.txt"); let output = filter_sbt_test(input); - let input_tokens = count_tokens(input); - let output_tokens = count_tokens(&output); + assert!(output.contains("4 passed"), "output: {}", output); + assert!(output.contains("2 failed")); + // Mockito-specific detail lines must appear + assert!( + output.contains("WantedButNotInvoked"), + "missing Mockito detail: {}", + output + ); + assert!(output.contains("Wanted but not invoked")); + assert!( + output.contains("TooManyActualInvocations"), + "missing second Mockito failure: {}", + output + ); + // Pure JVM stack frames ("at com.example...") must be suppressed; + // Mockito pointer lines ("-> at com.example...") may remain — they + // carry the file:line reference that identifies the assertion site. + assert!( + !output.lines().any(|l| l.trim_start().starts_with("at com.")), + "bare stack frame leaked into output: {}", + output + ); + } - let savings = 100.0 - (output_tokens as f64 / input_tokens as f64 * 100.0); + #[test] + fn test_filter_sbt_test_mockito_token_savings() { + let input = include_str!("../../../tests/fixtures/sbt/sbt_test_mockito_fail.txt"); + let output = filter_sbt_test(input); + let savings = 100.0 + - (count_tokens(&output) as f64 / count_tokens(input) as f64 * 100.0); assert!( savings >= 40.0, - "sbt test (fail) filter: expected >=40% savings, got {:.1}% (input: {}, output: {})", - savings, - input_tokens, - output_tokens + "sbt test (mockito): expected >=40% savings, got {:.1}%", + savings ); } + // --- sbt test: ScalaMock expectation failures --- + + #[test] + fn test_filter_sbt_test_scalamock_failure_details() { + let input = include_str!("../../../tests/fixtures/sbt/sbt_test_scalamock_fail.txt"); + let output = filter_sbt_test(input); + + assert!(output.contains("5 passed"), "output: {}", output); + assert!(output.contains("2 failed")); + // ScalaMock-specific detail lines must appear + assert!( + output.contains("Unexpected call"), + "missing ScalaMock detail: {}", + output + ); + assert!(output.contains("Unsatisfied expectation")); + } + + #[test] + fn test_filter_sbt_test_scalamock_token_savings() { + let input = include_str!("../../../tests/fixtures/sbt/sbt_test_scalamock_fail.txt"); + let output = filter_sbt_test(input); + let savings = 100.0 + - (count_tokens(&output) as f64 / count_tokens(input) as f64 * 100.0); + assert!( + savings >= 40.0, + "sbt test (scalamock): expected >=40% savings, got {:.1}%", + savings + ); + } + + // --- integration tests (it:test, IntegrationTest/test) --- + + #[test] + fn test_filter_sbt_it_test_pass() { + let input = include_str!("../../../tests/fixtures/sbt/sbt_it_test_pass.txt"); + let output = filter_sbt_test(input); // same filter as sbt test + + assert!(output.starts_with("sbt test:"), "output: {}", output); + assert!(output.contains("5 passed")); + assert!(output.contains("2 suites")); + assert!(output.contains("18s")); + assert!(!output.contains('\n'), "all-pass output should be a single line"); + } + + #[test] + fn test_is_integration_test_cmd() { + assert!(is_integration_test_cmd("it:test")); + assert!(is_integration_test_cmd("IntegrationTest/test")); + assert!(is_integration_test_cmd("integration-test/test")); + assert!(is_integration_test_cmd("e2e/test")); + assert!(is_integration_test_cmd("it:test")); + assert!(!is_integration_test_cmd("test")); + assert!(!is_integration_test_cmd("compile")); + assert!(!is_integration_test_cmd("assembly")); + } + + // --- sbt compile --- + #[test] fn test_filter_sbt_compile_success() { let input = "[info] loading settings for project root from build.sbt ...\n\ - [info] Compiling 15 Scala sources to /target/scala-2.13/classes ...\n\ - [success] Total time: 12 s, completed Jan 15, 2025"; + [info] Compiling 15 Scala sources to /target/scala-2.13/classes ...\n\ + [success] Total time: 12 s, completed Jan 15, 2025"; let output = filter_sbt_compile(input); assert!(output.contains("sbt compile:")); @@ -566,7 +801,7 @@ mod tests { #[test] fn test_filter_sbt_compile_errors() { - let input = include_str!("../tests/fixtures/sbt/sbt_compile_error.txt"); + let input = include_str!("../../../tests/fixtures/sbt/sbt_compile_error.txt"); let output = filter_sbt_compile(input); assert!(output.contains("sbt compile:")); @@ -577,31 +812,28 @@ mod tests { #[test] fn test_filter_sbt_compile_error_token_savings() { - let input = include_str!("../tests/fixtures/sbt/sbt_compile_error.txt"); + let input = include_str!("../../../tests/fixtures/sbt/sbt_compile_error.txt"); let output = filter_sbt_compile(input); - - let input_tokens = count_tokens(input); - let output_tokens = count_tokens(&output); - - let savings = 100.0 - (output_tokens as f64 / input_tokens as f64 * 100.0); + let savings = 100.0 + - (count_tokens(&output) as f64 / count_tokens(input) as f64 * 100.0); assert!( savings >= 30.0, - "sbt compile (error) filter: expected >=30% savings, got {:.1}% (input: {}, output: {})", - savings, - input_tokens, - output_tokens + "sbt compile (error): expected >=30% savings, got {:.1}%", + savings ); } + // --- sbt run --- + #[test] fn test_filter_sbt_run_strips_noise() { let input = "[info] welcome to sbt 1.9.7\n\ - [info] loading settings for project root from build.sbt ...\n\ - [info] set current project to myapp\n\ - [info] running com.example.Main\n\ - [info] Hello, World!\n\ - [info] Server started on port 8080\n\ - [success] Total time: 3 s, completed Jan 15, 2025"; + [info] loading settings for project root from build.sbt ...\n\ + [info] set current project to myapp\n\ + [info] running com.example.Main\n\ + [info] Hello, World!\n\ + [info] Server started on port 8080\n\ + [success] Total time: 3 s, completed Jan 15, 2025"; let output = filter_sbt_run(input); assert!(output.contains("Hello, World!")); @@ -613,6 +845,8 @@ mod tests { assert!(!output.contains("Total time:")); } + // --- edge cases --- + #[test] fn test_filter_sbt_test_empty_input() { let output = filter_sbt_test(""); @@ -628,8 +862,6 @@ mod tests { #[test] fn test_filter_sbt_run_empty_input() { - let output = filter_sbt_run(""); - // Empty input produces empty output - assert!(output.is_empty()); + assert!(filter_sbt_run("").is_empty()); } } diff --git a/tests/fixtures/sbt/sbt_it_test_pass.txt b/tests/fixtures/sbt/sbt_it_test_pass.txt new file mode 100644 index 000000000..b3088f41b --- /dev/null +++ b/tests/fixtures/sbt/sbt_it_test_pass.txt @@ -0,0 +1,21 @@ +[info] welcome to sbt 1.9.7 (Eclipse Adoptium Java 17.0.9) +[info] loading settings for project root-build from plugins.sbt ... +[info] loading project definition from /home/user/promotion/project +[info] loading settings for project root from build.sbt ... +[info] set current project to promotion (in build file:/home/user/promotion/) +[info] Compiling 6 Scala sources to /home/user/promotion/target/scala-2.13/it-classes ... +[info] PromotionIntegrationSpec: +[info] Promotion API integration +[info] - should apply discount end-to-end +[info] - should reject expired promotions +[info] - should handle concurrent promotion requests +[info] DatabaseIntegrationSpec: +[info] Database integration +[info] - should persist and retrieve promotions +[info] - should rollback on failure +[info] Run completed in 18 seconds, 542 milliseconds. +[info] Total number of tests run: 5 +[info] Suites: completed 2, aborted 0 +[info] Tests: succeeded 5, failed 0, canceled 0, ignored 0, pending 0 +[info] All tests passed. +[success] Total time: 22 s, completed Apr 3, 2026 14:30:05 diff --git a/tests/fixtures/sbt/sbt_test_mockito_fail.txt b/tests/fixtures/sbt/sbt_test_mockito_fail.txt new file mode 100644 index 000000000..7b950eaab --- /dev/null +++ b/tests/fixtures/sbt/sbt_test_mockito_fail.txt @@ -0,0 +1,33 @@ +[info] welcome to sbt 1.9.7 (Eclipse Adoptium Java 17.0.9) +[info] loading settings for project root-build from plugins.sbt ... +[info] loading project definition from /home/user/promotion/project +[info] loading settings for project root from build.sbt ... +[info] set current project to promotion (in build file:/home/user/promotion/) +[info] Compiling 4 Scala sources to /home/user/promotion/target/scala-2.13/test-classes ... +[info] PromotionServiceSpec: +[info] A PromotionService +[info] - should apply discount when user is eligible +[info] - should not apply discount when user is ineligible *** FAILED *** +[info] org.mockito.exceptions.verification.WantedButNotInvoked: +[info] Wanted but not invoked: +[info] -> at com.example.promotion.PromotionServiceSpec.$anonfun$new$4(PromotionServiceSpec.scala:52) +[info] However, there were other interactions with this mock: +[info] -> at com.example.promotion.PromotionServiceSpec.$anonfun$new$3(PromotionServiceSpec.scala:45) +[info] - should calculate final price correctly *** FAILED *** +[info] org.mockito.exceptions.verification.TooManyActualInvocations: +[info] userRepository.findById(42L) wanted 1 time but was called 2 times. +[info] -> at com.example.promotion.PromotionServiceSpec.$anonfun$new$6(PromotionServiceSpec.scala:71) +[info] - should persist promotion result +[info] UserRepositorySpec: +[info] A UserRepository +[info] - should find user by id +[info] - should return None for missing user +[info] Run completed in 6 seconds, 103 milliseconds. +[info] Total number of tests run: 6 +[info] Suites: completed 2, aborted 0 +[info] Tests: succeeded 4, failed 2, canceled 0, ignored 0, pending 0 +[info] *** 2 TESTS FAILED *** +[error] Failed tests: +[error] com.example.promotion.PromotionServiceSpec +[error] (Test / test) sbt.TestsFailedException: Tests unsuccessful +[error] Total time: 9 s, completed Apr 3, 2026 14:22:11 diff --git a/tests/fixtures/sbt/sbt_test_scalamock_fail.txt b/tests/fixtures/sbt/sbt_test_scalamock_fail.txt new file mode 100644 index 000000000..d9d50606d --- /dev/null +++ b/tests/fixtures/sbt/sbt_test_scalamock_fail.txt @@ -0,0 +1,31 @@ +[info] welcome to sbt 1.10.1 (Eclipse Adoptium Java 21.0.2) +[info] loading settings for project root-build from plugins.sbt ... +[info] loading project definition from /home/user/cass-reconciliation/project +[info] loading settings for project root from build.sbt ... +[info] set current project to cass-reconciliation (in build file:/home/user/cass-reconciliation/) +[info] Compiling 5 Scala sources to /home/user/cass-reconciliation/target/scala-3.3.7/test-classes ... +[info] ReconciliationServiceSpec: +[info] CassReconciliationService +[info] - should reconcile matching records +[info] - should detect and report mismatches *** FAILED *** +[info] Unexpected call: cassandraRepository.findByPartitionKey("partition-abc") +[info] com.example.reconciliation.ReconciliationServiceSpec$$anon$1.findByPartitionKey(ReconciliationServiceSpec.scala:67) +[info] - should skip already reconciled records +[info] - should emit metrics on completion *** FAILED *** +[info] Unsatisfied expectation: +[info] Expected exactly 1 call to: metricsClient.increment("reconciliation.completed") +[info] Actual calls: 0 +[info] - should handle empty input gracefully +[info] CassandraRepositorySpec: +[info] CassandraRepository +[info] - should fetch records by partition key +[info] - should handle timeout errors +[info] Run completed in 4 seconds, 891 milliseconds. +[info] Total number of tests run: 7 +[info] Suites: completed 2, aborted 0 +[info] Tests: succeeded 5, failed 2, canceled 0, ignored 0, pending 0 +[info] *** 2 TESTS FAILED *** +[error] Failed tests: +[error] com.example.reconciliation.ReconciliationServiceSpec +[error] (Test / test) sbt.TestsFailedException: Tests unsuccessful +[error] Total time: 7 s, completed Apr 3, 2026 14:25:44 From 1640eaad0dfb1d2afddcffb00cba970eb9094374 Mon Sep 17 00:00:00 2001 From: Ivan Severino <4858703+randomBrainstormer@users.noreply.github.com> Date: Fri, 15 May 2026 00:04:06 +0200 Subject: [PATCH 004/154] fix(sbt): add Debug derive to SbtCommands to satisfy Commands enum requirement Co-Authored-By: Claude Sonnet 4.6 --- src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index ee4e2610e..e1bbb4fff 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1130,7 +1130,7 @@ enum GoCommands { Other(Vec), } -#[derive(Subcommand)] +#[derive(Debug, Subcommand)] enum SbtCommands { /// Run tests with compact output (90% token reduction via ScalaTest filtering) Test { From 3586e8b3bcf43f162e8e5980bfefb536d50cb83f Mon Sep 17 00:00:00 2001 From: Ivan Severino <4858703+randomBrainstormer@users.noreply.github.com> Date: Sun, 17 May 2026 11:46:38 +0200 Subject: [PATCH 005/154] fix(sbt): add munit summary support and fix misleading parse error label Add MUNIT_SUMMARY_RE to recognize the munit/discipline-munit summary format: [info] Passed: Total N, Failed N, Errors N, Passed N [info] Failed: Total N, Failed N, Errors N, Passed N munit is the default test framework in Scala 3 templates and is used by major libraries (typelevel/cats via discipline-munit). Without this, filter_sbt_test fell through to the error-recovery path for every munit project, which also printed a misleading "sbt test: parse error" header. Changes: - Add MUNIT_SUMMARY_RE regex and parse branch in filter_sbt_test - Change fallback label from "sbt test: parse error" to "sbt test: errors" - Add munit pass/fail fixtures and tests covering both Scala 2 (ScalaTest) and Scala 3 (munit) output formats --- Cargo.lock | 285 ++++++++++++--------- src/cmds/scala/sbt_cmd.rs | 55 +++- tests/fixtures/sbt/sbt_test_munit_fail.txt | 36 +++ tests/fixtures/sbt/sbt_test_munit_pass.txt | 23 ++ 4 files changed, 271 insertions(+), 128 deletions(-) create mode 100644 tests/fixtures/sbt/sbt_test_munit_fail.txt create mode 100644 tests/fixtures/sbt/sbt_test_munit_pass.txt diff --git a/Cargo.lock b/Cargo.lock index 8773a0899..12112be4f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -40,9 +40,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.21" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", "anstyle-parse", @@ -55,15 +55,15 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" -version = "0.2.7" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] @@ -102,9 +102,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "automod" -version = "1.0.16" +version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8b5778837666541195063243828c5b6139221b47dc4ec3ba81738e532469ab1" +checksum = "a273e731a7fb8c2268fd2932c9e9a3469f4fd171d85d070d2687190229653bd3" dependencies = [ "proc-macro2", "quote", @@ -119,9 +119,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bitflags" -version = "2.11.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] name = "block-buffer" @@ -150,9 +150,9 @@ checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] name = "cc" -version = "1.2.56" +version = "1.2.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", "shlex", @@ -179,9 +179,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.60" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", "clap_derive", @@ -189,9 +189,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.60" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ "anstream", "anstyle", @@ -201,9 +201,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.55" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ "heck", "proc-macro2", @@ -213,15 +213,15 @@ dependencies = [ [[package]] name = "clap_lex" -version = "1.0.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "colorchoice" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "colored" @@ -334,12 +334,6 @@ dependencies = [ "syn", ] -[[package]] -name = "env_home" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" - [[package]] name = "equivalent" version = "1.0.2" @@ -370,9 +364,9 @@ checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" [[package]] name = "fastrand" -version = "2.3.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "find-msvc-tools" @@ -405,6 +399,30 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -472,9 +490,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.16.1" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "hashlink" @@ -517,12 +535,13 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -530,9 +549,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -543,9 +562,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -557,15 +576,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ "icu_collections", "icu_locale_core", @@ -577,15 +596,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", @@ -615,9 +634,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", @@ -641,12 +660,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.13.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -659,16 +678,18 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itoa" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.91" +version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] @@ -687,15 +708,15 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.182" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libredox" -version = "0.1.14" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ "libc", ] @@ -719,9 +740,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "log" @@ -756,9 +777,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "once_cell_polyfill" @@ -778,17 +799,23 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + [[package]] name = "pkg-config" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "potential_utf" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ "zerovec", ] @@ -947,9 +974,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.37" +version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "log", "once_cell", @@ -962,9 +989,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.0" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ "zeroize", ] @@ -997,9 +1024,9 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" @@ -1073,9 +1100,15 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "simd-adler32" -version = "0.3.8" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "slab" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" @@ -1125,9 +1158,9 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.26.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", "getrandom 0.4.2", @@ -1158,9 +1191,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", @@ -1209,9 +1242,9 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" [[package]] name = "unicode-ident" @@ -1301,11 +1334,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" +version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.57.1", ] [[package]] @@ -1314,14 +1347,14 @@ version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.51.0", ] [[package]] name = "wasm-bindgen" -version = "0.2.114" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" dependencies = [ "cfg-if", "once_cell", @@ -1332,9 +1365,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.114" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1342,9 +1375,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.114" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" dependencies = [ "bumpalo", "proc-macro2", @@ -1355,9 +1388,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.114" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" dependencies = [ "unicode-ident", ] @@ -1402,27 +1435,25 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.6", + "webpki-roots 1.0.7", ] [[package]] name = "webpki-roots" -version = "1.0.6" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" dependencies = [ "rustls-pki-types", ] [[package]] name = "which" -version = "8.0.1" +version = "8.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a824aeba0fbb27264f815ada4cff43d65b1741b7a4ed7629ff9089148c4a4e0" +checksum = "81995fafaaaf6ae47a7d0cc83c67caf92aeb7e5331650ae6ff856f7c0c60c459" dependencies = [ - "env_home", - "rustix", - "winsafe", + "libc", ] [[package]] @@ -1659,12 +1690,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "winsafe" -version = "0.0.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" - [[package]] name = "wit-bindgen" version = "0.51.0" @@ -1674,6 +1699,12 @@ dependencies = [ "wit-bindgen-rust-macro", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "wit-bindgen-core" version = "0.51.0" @@ -1755,15 +1786,15 @@ dependencies = [ [[package]] name = "writeable" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "yoke" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -1772,9 +1803,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", @@ -1784,18 +1815,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.40" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a789c6e490b576db9f7e6b6d661bcc9799f7c0ac8352f56ea20193b2681532e5" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.40" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", @@ -1804,18 +1835,18 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", @@ -1831,9 +1862,9 @@ checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" [[package]] name = "zerotrie" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", "yoke", @@ -1842,9 +1873,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", @@ -1853,9 +1884,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", diff --git a/src/cmds/scala/sbt_cmd.rs b/src/cmds/scala/sbt_cmd.rs index c6fd289c6..30d1ad918 100644 --- a/src/cmds/scala/sbt_cmd.rs +++ b/src/cmds/scala/sbt_cmd.rs @@ -12,6 +12,13 @@ lazy_static! { r"Tests: succeeded (\d+), failed (\d+), canceled (\d+), ignored (\d+), pending (\d+)" ).unwrap(); + /// Matches the munit summary line (also used by discipline-munit / ZIO Test): + /// [info] Passed: Total N, Failed N, Errors N, Passed N + /// [info] Failed: Total N, Failed N, Errors N, Passed N + static ref MUNIT_SUMMARY_RE: Regex = Regex::new( + r"^\[info\] (?:Passed|Failed): Total \d+, Failed (\d+), Errors (\d+), Passed (\d+)" + ).unwrap(); + /// Matches suite count line: /// Suites: completed N, aborted N static ref SUITE_SUMMARY_RE: Regex = Regex::new( @@ -312,6 +319,15 @@ fn filter_sbt_test(output: &str) -> String { in_failure_detail = false; continue; } + if let Some(caps) = MUNIT_SUMMARY_RE.captures(trimmed) { + let munit_failed: u32 = caps[1].parse().unwrap_or(0); + let errors: u32 = caps[2].parse().unwrap_or(0); + succeeded = caps[3].parse().unwrap_or(0); + failed = munit_failed + errors; + has_summary = true; + in_failure_detail = false; + continue; + } if let Some(caps) = SUITE_SUMMARY_RE.captures(trimmed) { suites = caps[1].parse().unwrap_or(0); in_failure_detail = false; @@ -406,7 +422,7 @@ fn filter_sbt_test(output: &str) -> String { if !has_summary { if !error_lines.is_empty() { - let mut result = String::from("sbt test: parse error\n"); + let mut result = String::from("sbt test: errors\n"); result.push_str("═══════════════════════════════════════\n"); for line in error_lines.iter().take(20) { result.push_str(&format!(" {}\n", truncate(line, 120))); @@ -785,6 +801,43 @@ mod tests { assert!(!is_integration_test_cmd("assembly")); } + // --- sbt test: munit format --- + + #[test] + fn test_filter_sbt_test_munit_pass() { + let input = include_str!("../../../tests/fixtures/sbt/sbt_test_munit_pass.txt"); + let output = filter_sbt_test(input); + + assert!(output.starts_with("sbt test:"), "output: {}", output); + assert!(output.contains("12 passed"), "output: {}", output); + assert!(!output.contains('\n'), "all-pass output should be a single line"); + assert!(!output.contains("parse error"), "output: {}", output); + } + + #[test] + fn test_filter_sbt_test_munit_fail() { + let input = include_str!("../../../tests/fixtures/sbt/sbt_test_munit_fail.txt"); + let output = filter_sbt_test(input); + + assert!(output.contains("8 passed"), "output: {}", output); + assert!(output.contains("2 failed"), "output: {}", output); + assert!(output.contains("[FAIL]"), "output: {}", output); + assert!(!output.contains("parse error"), "output: {}", output); + } + + #[test] + fn test_filter_sbt_test_munit_token_savings() { + let input = include_str!("../../../tests/fixtures/sbt/sbt_test_munit_pass.txt"); + let output = filter_sbt_test(input); + let savings = 100.0 + - (count_tokens(&output) as f64 / count_tokens(input) as f64 * 100.0); + assert!( + savings >= 60.0, + "sbt test (munit): expected >=60% savings, got {:.1}%", + savings + ); + } + // --- sbt compile --- #[test] diff --git a/tests/fixtures/sbt/sbt_test_munit_fail.txt b/tests/fixtures/sbt/sbt_test_munit_fail.txt new file mode 100644 index 000000000..120558dbb --- /dev/null +++ b/tests/fixtures/sbt/sbt_test_munit_fail.txt @@ -0,0 +1,36 @@ +[info] welcome to sbt 1.9.7 (Temurin Java 17.0.9) +[info] loading project definition from /home/user/myproject/project +[info] loading settings for project myproject from build.sbt +[info] set current project to myproject (in build file:/home/user/myproject/) +[info] Done updating. +[info] compiling 1 Scala source to /home/user/myproject/target/scala-3.3.1/test-classes ... +[info] MySuite: +[info] + test addition 0.013s +[info] - test subtraction *** FAILED *** +[info] Assertion failed: 5 - 3 was not equal to 3 +[info] Values obtained: +[info] lhs: 2 +[info] rhs: 3 +[info] at MySuite.scala:18 +[info] + test string equality 0.002s +[info] + test list operations 0.006s +[info] MathSuite: +[info] + factorial of 0 is 1 0.001s +[info] - factorial of 5 is 120 *** FAILED *** +[info] Assertion failed: 60 was not equal to 120 +[info] Values obtained: +[info] lhs: 60 +[info] rhs: 120 +[info] at MathSuite.scala:34 +[info] + fibonacci 0.002s +[info] HttpClientSuite: +[info] + GET request succeeds 0.045s +[info] + POST request with body 0.038s +[info] + handles 404 response 0.021s +[info] + handles connection timeout 0.019s +[info] Failed: Total 10, Failed 2, Errors 0, Passed 8 +[error] Failed tests: +[error] com.example.MySuite +[error] com.example.MathSuite +[error] (Test / test) sbt.TestsFailedException: Tests unsuccessful +[error] Total time: 9 s, completed May 17, 2026 10:15:00 AM diff --git a/tests/fixtures/sbt/sbt_test_munit_pass.txt b/tests/fixtures/sbt/sbt_test_munit_pass.txt new file mode 100644 index 000000000..234d1102a --- /dev/null +++ b/tests/fixtures/sbt/sbt_test_munit_pass.txt @@ -0,0 +1,23 @@ +[info] welcome to sbt 1.9.7 (Temurin Java 17.0.9) +[info] loading project definition from /home/user/myproject/project +[info] loading settings for project myproject from build.sbt +[info] set current project to myproject (in build file:/home/user/myproject/) +[info] Done updating. +[info] compiling 1 Scala source to /home/user/myproject/target/scala-3.3.1/test-classes ... +[info] MySuite: +[info] + test addition 0.013s +[info] + test subtraction 0.004s +[info] + test string equality 0.002s +[info] + test list operations 0.006s +[info] + test option handling 0.003s +[info] MathSuite: +[info] + factorial of 0 is 1 0.001s +[info] + factorial of 5 is 120 0.001s +[info] + fibonacci 0.002s +[info] HttpClientSuite: +[info] + GET request succeeds 0.045s +[info] + POST request with body 0.038s +[info] + handles 404 response 0.021s +[info] + handles connection timeout 0.019s +[info] Passed: Total 12, Failed 0, Errors 0, Passed 12 +[success] Total time: 8 s, completed May 17, 2026 10:15:00 AM From 189409bd308aec6b31c81cc1b092247cb1216dcd Mon Sep 17 00:00:00 2001 From: Ivan Severino <4858703+randomBrainstormer@users.noreply.github.com> Date: Mon, 25 May 2026 14:46:05 +0200 Subject: [PATCH 006/154] feat(sbt): support -batch flag and scoped task notation (Test/compile) --- src/cmds/scala/sbt_cmd.rs | 35 ++++++--- src/main.rs | 151 +++++++++++++++++++++++++++++++++++++- 2 files changed, 175 insertions(+), 11 deletions(-) diff --git a/src/cmds/scala/sbt_cmd.rs b/src/cmds/scala/sbt_cmd.rs index 30d1ad918..a967f3045 100644 --- a/src/cmds/scala/sbt_cmd.rs +++ b/src/cmds/scala/sbt_cmd.rs @@ -60,18 +60,29 @@ fn is_integration_test_cmd(subcommand: &str) -> bool { ) || (subcommand.ends_with(":test") || subcommand.ends_with("/test")) } +/// Returns true if `s` is a scoped SBT task (e.g. `Test/test`, `it/Test/compile`). +fn is_scoped_task(s: &str) -> bool { + !s.starts_with('-') && (s.contains('/') || s.contains(':')) +} + pub fn run_test(args: &[String], verbose: u8) -> Result<()> { let timer = tracking::TimedExecution::start(); let mut cmd = resolved_command("sbt"); - cmd.arg("test"); - for arg in args { + // If caller passed a scoped task as first arg (e.g. "Test/test"), use it directly + // so sbt runs the right configuration scope. + let (sbt_task, rest) = match args.first() { + Some(a) if is_scoped_task(a) => (a.as_str(), &args[1..]), + _ => ("test", args), + }; + cmd.arg(sbt_task); + for arg in rest { cmd.arg(arg); } if verbose > 0 { - eprintln!("Running: sbt test {}", args.join(" ")); + eprintln!("Running: sbt {} {}", sbt_task, rest.join(" ")); } let output = cmd @@ -95,8 +106,8 @@ pub fn run_test(args: &[String], verbose: u8) -> Result<()> { } timer.track( - &format!("sbt test {}", args.join(" ")), - &format!("rtk sbt test {}", args.join(" ")), + &format!("sbt {} {}", sbt_task, rest.join(" ")), + &format!("rtk sbt {} {}", sbt_task, rest.join(" ")), &raw, &filtered, ); @@ -112,14 +123,18 @@ pub fn run_compile(args: &[String], verbose: u8) -> Result<()> { let timer = tracking::TimedExecution::start(); let mut cmd = resolved_command("sbt"); - cmd.arg("compile"); - for arg in args { + let (sbt_task, rest) = match args.first() { + Some(a) if is_scoped_task(a) => (a.as_str(), &args[1..]), + _ => ("compile", args), + }; + cmd.arg(sbt_task); + for arg in rest { cmd.arg(arg); } if verbose > 0 { - eprintln!("Running: sbt compile {}", args.join(" ")); + eprintln!("Running: sbt {} {}", sbt_task, rest.join(" ")); } let output = cmd @@ -147,8 +162,8 @@ pub fn run_compile(args: &[String], verbose: u8) -> Result<()> { } timer.track( - &format!("sbt compile {}", args.join(" ")), - &format!("rtk sbt compile {}", args.join(" ")), + &format!("sbt {} {}", sbt_task, rest.join(" ")), + &format!("rtk sbt {} {}", sbt_task, rest.join(" ")), &raw, &filtered, ); diff --git a/src/main.rs b/src/main.rs index e1bbb4fff..95d5f127b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1420,11 +1420,83 @@ where } } +/// Strip SBT global flags (e.g. `-batch`) and normalize scoped tasks before Clap parsing. +/// +/// SBT accepts global JVM/session flags before the task name. Claude routinely runs +/// `sbt -batch Test/compile` or `sbt -batch it/Test/test`. Clap can't parse `-batch` +/// (single-dash long flag) so these fall through to unfiltered passthrough without this. +/// +/// Examples: +/// `sbt -batch compile` → `sbt compile` +/// `sbt -batch Test/compile` → `sbt compile Test/compile` +/// `sbt -Dsbt.log.format=false Test/test` → `sbt test Test/test` +fn preprocess_sbt_args(args: Vec) -> Vec { + if args.len() < 3 || args[1].to_str() != Some("sbt") { + return args; + } + + let sbt_rest = &args[2..]; + + // Find the first arg that isn't a global flag + let subcmd_offset = sbt_rest + .iter() + .position(|a| !is_sbt_global_flag(a.to_str().unwrap_or(""))); + + let subcmd_offset = match subcmd_offset { + Some(i) => i, + None => return args, // only flags, no task — let Clap produce the error + }; + + let subcmd = sbt_rest[subcmd_offset].to_str().unwrap_or("").to_string(); + let normalized = normalize_sbt_task(&subcmd); + + // Nothing to change: no global flags stripped and task already matches + if subcmd_offset == 0 && normalized == subcmd { + return args; + } + + // Rebuild: [rtk, sbt, , , ...rest] + let mut result = vec![args[0].clone(), args[1].clone()]; + result.push(OsString::from(&normalized)); + if normalized != subcmd { + // Keep the original scoped task (e.g. "Test/compile") as the first arg so + // run_compile / run_test can forward it verbatim to sbt. + result.push(OsString::from(&subcmd)); + } + result.extend_from_slice(&sbt_rest[subcmd_offset + 1..]); + result +} + +fn is_sbt_global_flag(s: &str) -> bool { + matches!( + s, + "-batch" | "--batch" | "-no-colors" | "--no-colors" | "--color=false" + ) || s.starts_with("-D") + || s.starts_with("-J") + || s.starts_with("--color=") +} + +/// Map a (possibly scoped) SBT task to its Clap subcommand name. +/// `Test/compile` → `"compile"`, `it/Test/test` → `"test"`, `compile` → `"compile"`. +fn normalize_sbt_task(task: &str) -> String { + let base = task + .rsplit('/') + .next() + .or_else(|| task.rsplit(':').next()) + .unwrap_or(task); + match base { + "compile" | "Compile" => "compile".to_string(), + "test" | "Test" => "test".to_string(), + "run" | "Run" => "run".to_string(), + _ => task.to_string(), + } +} + fn run_cli() -> Result { // Fire-and-forget telemetry ping (1/day, non-blocking) core::telemetry::maybe_ping(); - let cli = match Cli::try_parse() { + let cli = match Cli::try_parse_from(preprocess_sbt_args(std::env::args_os().collect())) { Ok(cli) => cli, Err(e) => { if matches!(e.kind(), ErrorKind::DisplayHelp | ErrorKind::DisplayVersion) { @@ -3260,4 +3332,81 @@ mod tests { _ => panic!("Expected Init command"), } } + + fn os_args(args: &[&str]) -> Vec { + args.iter().map(OsString::from).collect() + } + + #[test] + fn test_preprocess_sbt_batch_compile() { + let input = os_args(&["rtk", "sbt", "-batch", "compile"]); + let result = preprocess_sbt_args(input); + assert_eq!(result, os_args(&["rtk", "sbt", "compile"])); + } + + #[test] + fn test_preprocess_sbt_batch_scoped_compile() { + let input = os_args(&["rtk", "sbt", "-batch", "Test/compile"]); + let result = preprocess_sbt_args(input); + assert_eq!(result, os_args(&["rtk", "sbt", "compile", "Test/compile"])); + } + + #[test] + fn test_preprocess_sbt_batch_integration_compile() { + let input = os_args(&["rtk", "sbt", "-batch", "it/Test/compile"]); + let result = preprocess_sbt_args(input); + assert_eq!( + result, + os_args(&["rtk", "sbt", "compile", "it/Test/compile"]) + ); + } + + #[test] + fn test_preprocess_sbt_batch_test() { + let input = os_args(&["rtk", "sbt", "-batch", "Test/test"]); + let result = preprocess_sbt_args(input); + assert_eq!(result, os_args(&["rtk", "sbt", "test", "Test/test"])); + } + + #[test] + fn test_preprocess_sbt_scoped_no_batch() { + let input = os_args(&["rtk", "sbt", "Test/compile"]); + let result = preprocess_sbt_args(input); + assert_eq!(result, os_args(&["rtk", "sbt", "compile", "Test/compile"])); + } + + #[test] + fn test_preprocess_sbt_plain_compile_unchanged() { + let input = os_args(&["rtk", "sbt", "compile"]); + let result = preprocess_sbt_args(input); + assert_eq!(result, os_args(&["rtk", "sbt", "compile"])); + } + + #[test] + fn test_preprocess_sbt_dsbt_flag() { + let input = os_args(&["rtk", "sbt", "-Dsbt.log.noformat=true", "compile"]); + let result = preprocess_sbt_args(input); + assert_eq!(result, os_args(&["rtk", "sbt", "compile"])); + } + + #[test] + fn test_preprocess_non_sbt_unchanged() { + let input = os_args(&["rtk", "cargo", "test"]); + let result = preprocess_sbt_args(input); + assert_eq!(result, os_args(&["rtk", "cargo", "test"])); + } + + #[test] + fn test_preprocess_sbt_batch_routes_to_clap() { + let args = preprocess_sbt_args(os_args(&["rtk", "sbt", "-batch", "Test/compile"])); + let cli = Cli::try_parse_from(args).unwrap(); + match cli.command { + Commands::Sbt { + command: SbtCommands::Compile { args }, + } => { + assert_eq!(args, vec!["Test/compile"]); + } + _ => panic!("Expected Sbt Compile"), + } + } } From 5e3e54a354ead746bdf5253d441d9d85e3454354 Mon Sep 17 00:00:00 2001 From: sasha Date: Mon, 20 Apr 2026 01:28:13 +0300 Subject: [PATCH 007/154] feat: add uv run support --- README.md | 1 + src/cmds/python/README.md | 1 + src/cmds/python/uv_cmd.rs | 369 +++++++++++++++++++++++ src/discover/registry.rs | 43 +++ src/discover/rules.rs | 9 + src/hooks/init.rs | 2 + src/main.rs | 12 +- tests/fixtures/uv_run_pytest_failure.txt | 28 ++ 8 files changed, 464 insertions(+), 1 deletion(-) create mode 100644 src/cmds/python/uv_cmd.rs create mode 100644 tests/fixtures/uv_run_pytest_failure.txt diff --git a/README.md b/README.md index d91e21b24..74afbd727 100644 --- a/README.md +++ b/README.md @@ -203,6 +203,7 @@ rtk rubocop # Ruby linting (JSON, -60%+) ### Package Managers ```bash rtk pnpm list # Compact dependency tree +rtk uv run pytest # Preserve uv env, errors only rtk pip list # Python packages (auto-detect uv) rtk pip outdated # Outdated packages rtk bundle install # Ruby gems (strip Using lines) diff --git a/src/cmds/python/README.md b/src/cmds/python/README.md index 7295ded35..912b69869 100644 --- a/src/cmds/python/README.md +++ b/src/cmds/python/README.md @@ -7,6 +7,7 @@ - `pytest_cmd.rs` uses a state machine text parser (no JSON available from pytest) - `ruff_cmd.rs` uses JSON for check mode (`--output-format=json`) and text filtering for format mode - `pip_cmd.rs` auto-detects `uv` as a pip alternative and routes accordingly +- `uv_cmd.rs` preserves `uv run` environment semantics while filtering down to relevant failures - `python -m pytest` and `python3 -m mypy` are rewritten by the hook registry to `rtk pytest` / `rtk mypy` ## Cross-command diff --git a/src/cmds/python/uv_cmd.rs b/src/cmds/python/uv_cmd.rs new file mode 100644 index 000000000..9ef0bbd7f --- /dev/null +++ b/src/cmds/python/uv_cmd.rs @@ -0,0 +1,369 @@ +//! Filters `uv run` output while preserving uv-managed environment semantics. + +use crate::core::runner; +use crate::core::stream::{self, FilterMode, StdinMode}; +use crate::core::tracking; +use crate::core::truncate::CAP_WARNINGS; +use crate::core::utils::{exit_code_from_status, resolved_command, strip_ansi, truncate}; +use anyhow::{Context, Result}; +use lazy_static::lazy_static; +use regex::Regex; + +lazy_static! { + static ref PYTHON_FRAME_RE: Regex = Regex::new(r#"^\s*File ".*", line \d+.*$"#).unwrap(); + static ref PYTHON_EXCEPTION_RE: Regex = + Regex::new(r"^\s*[A-Za-z_][A-Za-z0-9_.]*(?:Error|Exception):").unwrap(); + static ref JS_FRAME_RE: Regex = Regex::new(r"^\s*at .+:\d+:\d+.*$").unwrap(); + static ref ERROR_START_PATTERNS: Vec = vec![ + Regex::new(r"(?i)\berror\b").unwrap(), + Regex::new(r"(?i)\bfailed\b").unwrap(), + Regex::new(r"(?i)\bfailure\b").unwrap(), + Regex::new(r"(?i)\bexception\b").unwrap(), + Regex::new(r"(?i)\bpanic\b").unwrap(), + Regex::new(r"(?i)\bwarn(?:ing)?\b").unwrap(), + Regex::new(r"(?i)\bassert(?:ion)?\b").unwrap(), + Regex::new(r"^\s*FAILED\b").unwrap(), + Regex::new(r"^\s*ERROR\b").unwrap(), + Regex::new(r"^\s*E\s+").unwrap(), + Regex::new(r"^\s*Caused by:").unwrap(), + Regex::new(r"^\s*note:").unwrap(), + Regex::new(r"^\s*help:").unwrap(), + ]; +} + +const MAX_TRACEBACK_FRAMES: usize = CAP_WARNINGS; +const MAX_ERROR_CONTINUATION_LINES: usize = CAP_WARNINGS; +const MAX_FALLBACK_TAIL_LINES: usize = CAP_WARNINGS; + +pub fn run(args: &[String], verbose: u8) -> Result { + let timer = tracking::TimedExecution::start(); + let args_display = args.join(" "); + let original_cmd = display_command("uv", &args_display); + let rtk_cmd = display_command("rtk uv", &args_display); + + let mut cmd = resolved_command("uv"); + cmd.args(args); + + if verbose > 0 { + eprintln!("Running: {}", original_cmd); + } + + if args.first().map(String::as_str) != Some("run") { + let status = cmd.status().context("Failed to run uv")?; + timer.track_passthrough(&original_cmd, &format!("{rtk_cmd} (passthrough)")); + return Ok(exit_code_from_status(&status, "uv")); + } + + let result = stream::run_streaming(&mut cmd, StdinMode::Inherit, FilterMode::CaptureOnly) + .context("Failed to run uv")?; + let filtered = filter_uv_run_output(&result.raw, result.exit_code); + + runner::print_with_hint(&filtered, &result.raw, "uv", result.exit_code); + timer.track(&original_cmd, &rtk_cmd, &result.raw, &filtered); + + Ok(result.exit_code) +} + +fn display_command(prefix: &str, args_display: &str) -> String { + if args_display.trim().is_empty() { + prefix.to_string() + } else { + format!("{prefix} {args_display}") + } +} + +fn filter_uv_run_output(output: &str, exit_code: i32) -> String { + let clean = strip_ansi(output); + let lines: Vec<&str> = clean.lines().collect(); + let mut selected: Vec = Vec::new(); + let mut i = 0; + + while i < lines.len() { + let line = lines[i]; + let trimmed = line.trim(); + + if trimmed.is_empty() { + i += 1; + continue; + } + + if is_traceback_start(trimmed) { + let (block, next_idx) = collect_traceback_block(&lines, i); + selected.extend(block); + selected.push(String::new()); + i = next_idx; + continue; + } + + if is_error_start(trimmed) { + let (block, next_idx) = collect_error_block(&lines, i); + selected.extend(block); + selected.push(String::new()); + i = next_idx; + continue; + } + + i += 1; + } + + let filtered = selected.join("\n").trim().to_string(); + if !filtered.is_empty() { + return filtered; + } + + if exit_code == 0 { + return "ok".to_string(); + } + + let tail: Vec = clean + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(|line| truncate(line, 200)) + .collect(); + + if tail.is_empty() { + return format!("[FAIL] uv run failed (exit code: {exit_code})"); + } + + let summary = tail + .into_iter() + .rev() + .take(MAX_FALLBACK_TAIL_LINES) + .collect::>() + .into_iter() + .rev() + .collect::>(); + + format!( + "[FAIL] uv run failed (exit code: {exit_code})\n{}", + summary.join("\n") + ) +} + +fn collect_traceback_block(lines: &[&str], start_idx: usize) -> (Vec, usize) { + let mut block = vec![lines[start_idx].trim().to_string()]; + let mut frames = Vec::new(); + let mut tail = Vec::new(); + let mut idx = start_idx + 1; + + while idx < lines.len() { + let trimmed = lines[idx].trim(); + if trimmed.is_empty() { + break; + } + + if PYTHON_FRAME_RE.is_match(trimmed) { + frames.push(truncate(trimmed, 160)); + } else { + tail.push(truncate(trimmed, 200)); + } + + idx += 1; + } + + block.extend(frames.iter().take(MAX_TRACEBACK_FRAMES).cloned()); + if frames.len() > MAX_TRACEBACK_FRAMES { + block.push(format!( + "... +{} more frames", + frames.len() - MAX_TRACEBACK_FRAMES + )); + let full_traceback = lines[start_idx..idx].join("\n"); + if let Some(hint) = crate::core::tee::force_tee_hint(&full_traceback, "uv-traceback") { + block.push(format!(" {hint}")); + } + } + + let tail_lines = tail + .into_iter() + .rev() + .take(2) + .collect::>() + .into_iter() + .rev() + .collect::>(); + block.extend(tail_lines); + + (dedupe_preserving_order(block), idx) +} + +fn collect_error_block(lines: &[&str], start_idx: usize) -> (Vec, usize) { + let mut block = vec![truncate(lines[start_idx].trim(), 200)]; + let mut continuation_count = 0; + let mut idx = start_idx + 1; + + while idx < lines.len() { + let line = lines[idx]; + let trimmed = line.trim(); + + if trimmed.is_empty() || !is_error_continuation(line) { + break; + } + + continuation_count += 1; + if continuation_count <= MAX_ERROR_CONTINUATION_LINES { + block.push(truncate(trimmed, 200)); + } + + idx += 1; + } + + if continuation_count > MAX_ERROR_CONTINUATION_LINES { + block.push(format!( + "... +{} more lines", + continuation_count - MAX_ERROR_CONTINUATION_LINES + )); + let full_block = lines[start_idx..idx].join("\n"); + if let Some(hint) = crate::core::tee::force_tee_hint(&full_block, "uv-error-block") { + block.push(format!(" {hint}")); + } + } + + (dedupe_preserving_order(block), idx) +} + +fn dedupe_preserving_order(lines: Vec) -> Vec { + let mut deduped = Vec::new(); + for line in lines { + if deduped.last() != Some(&line) { + deduped.push(line); + } + } + deduped +} + +fn is_traceback_start(line: &str) -> bool { + line.starts_with("Traceback ") +} + +fn is_error_start(line: &str) -> bool { + if is_traceback_start(line) + || PYTHON_FRAME_RE.is_match(line) + || PYTHON_EXCEPTION_RE.is_match(line) + || JS_FRAME_RE.is_match(line) + { + return true; + } + + if line.contains("No module named ") { + return true; + } + + ERROR_START_PATTERNS.iter().any(|pattern| pattern.is_match(line)) +} + +fn is_error_continuation(line: &str) -> bool { + let trimmed = line.trim(); + line.starts_with(' ') + || line.starts_with('\t') + || trimmed.starts_with('>') + || trimmed.starts_with('|') + || trimmed.starts_with("During handling of the above exception") + || trimmed.starts_with("The above exception") + || trimmed.starts_with("Caused by:") + || trimmed.starts_with("note:") + || trimmed.starts_with("help:") + || PYTHON_FRAME_RE.is_match(trimmed) + || PYTHON_EXCEPTION_RE.is_match(trimmed) + || JS_FRAME_RE.is_match(trimmed) +} + +#[cfg(test)] +mod tests { + use super::{filter_uv_run_output, MAX_TRACEBACK_FRAMES}; + use crate::core::utils::count_tokens; + + #[test] + fn test_filter_uv_run_suppresses_success_noise() { + let output = r#" +Using CPython 3.12.2 +Resolved 12 packages in 48ms +Installed 1 package in 5ms +hello from script +"#; + + assert_eq!(filter_uv_run_output(output, 0), "ok"); + } + + #[test] + fn test_filter_uv_run_truncates_python_tracebacks() { + let output = r#" +Traceback (most recent call last): + File "/tmp/project/main.py", line 10, in + run() + File "/tmp/project/app.py", line 22, in run + inner() + File "/tmp/project/lib.py", line 33, in inner + boom() + File "/tmp/project/helpers.py", line 44, in boom + raise RuntimeError("kaboom") +RuntimeError: kaboom +"#; + + let result = filter_uv_run_output(output, 1); + assert!(result.contains("Traceback (most recent call last):")); + assert!(result.contains(r#"File "/tmp/project/main.py", line 10, in "#)); + assert!(result.contains("RuntimeError: kaboom")); + assert!(!result.contains("run()")); + } + + #[test] + fn test_filter_uv_run_truncates_many_python_frames() { + let mut output = String::from("Traceback (most recent call last):\n"); + for i in 0..(MAX_TRACEBACK_FRAMES + 2) { + output.push_str(&format!( + " File \"/tmp/project/module_{i}.py\", line {i}, in call_{i}\n" + )); + output.push_str(" call_next()\n"); + } + output.push_str("RuntimeError: kaboom\n"); + + let result = filter_uv_run_output(&output, 1); + assert!(result.contains("Traceback (most recent call last):")); + assert!(result.contains("... +2 more frames")); + } + + #[test] + fn test_filter_uv_run_keeps_failure_summary_lines() { + let output = r#" +Resolved 8 packages in 30ms +============================= test session starts ============================= +FAILED tests/test_api.py::test_healthcheck - AssertionError: expected 200 +1 failed, 12 passed in 0.31s +"#; + + let result = filter_uv_run_output(output, 1); + assert!(result.contains("FAILED tests/test_api.py::test_healthcheck")); + assert!(result.contains("1 failed, 12 passed in 0.31s")); + assert!(!result.contains("Resolved 8 packages")); + } + + #[test] + fn test_filter_uv_run_has_failure_fallback() { + let output = "sync aborted by signal"; + let result = filter_uv_run_output(output, 2); + + assert!(result.contains("[FAIL] uv run failed (exit code: 2)")); + assert!(result.contains("sync aborted by signal")); + } + + #[test] + fn test_filter_uv_run_pytest_fixture_token_savings() { + let input = include_str!("../../../tests/fixtures/uv_run_pytest_failure.txt"); + let output = filter_uv_run_output(input, 1); + let input_tokens = count_tokens(input); + let output_tokens = count_tokens(&output); + let savings = 100.0 - (output_tokens as f64 / input_tokens as f64 * 100.0); + + assert!( + savings >= 70.0, + "uv run pytest: expected >=70% savings, got {:.1}% ({} -> {} tokens)", + savings, + input_tokens, + output_tokens + ); + assert!(output.contains("FAILED tests/test_users.py::test_normalize_user_rejects_empty")); + assert!(output.contains("1 failed, 1 passed")); + assert!(!output.contains("Downloading cpython")); + } +} diff --git a/src/discover/registry.rs b/src/discover/registry.rs index 59651a497..b9c0d423d 100644 --- a/src/discover/registry.rs +++ b/src/discover/registry.rs @@ -2350,6 +2350,49 @@ mod tests { ); } + #[test] + fn test_classify_uv_run() { + let commands = vec![ + "uv run python script.py", + "uv run pytest", + "uv run ruff check", + "uv run --project backend --extra dev python script.py", + ]; + + for command in commands { + assert!( + matches!( + classify_command(command), + Classification::Supported { + rtk_equivalent: "rtk uv", + .. + } + ), + "Failed for command: {}", + command + ); + } + } + + #[test] + fn test_rewrite_uv_run() { + let commands = vec![ + "uv run python script.py", + "uv run pytest", + "uv run ruff check", + "uv run --project backend --extra dev python script.py", + ]; + + for command in commands { + assert_eq!( + rewrite_command_no_prefixes(command, &[]), + Some(format!("rtk {command}")), + "Failed for command: {}", + command + ); + } + } + // --- Go tooling --- #[test] diff --git a/src/discover/rules.rs b/src/discover/rules.rs index 6bb5741c8..fc61227f7 100644 --- a/src/discover/rules.rs +++ b/src/discover/rules.rs @@ -461,6 +461,15 @@ pub const RULES: &[RtkRule] = &[ subcmd_savings: &[("list", 75.0), ("outdated", 80.0)], subcmd_status: &[], }, + RtkRule { + pattern: r"^uv\s+run(?:\s|$)", + rtk_cmd: "rtk uv", + rewrite_prefixes: &["uv"], + category: "Python", + savings_pct: 70.0, + subcmd_savings: &[], + subcmd_status: &[], + }, RtkRule { pattern: r"^go\s+(test|build|vet)", rtk_cmd: "rtk go", diff --git a/src/hooks/init.rs b/src/hooks/init.rs index 4c68ec49b..4d62b73d1 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -183,6 +183,7 @@ rtk pnpm install # Compact install output (90%) rtk npm run