From cd627a18a3505c0f4aa54401e01d722f478f0114 Mon Sep 17 00:00:00 2001 From: cmolder <28611108+cmolder@users.noreply.github.com> Date: Mon, 2 Mar 2026 12:28:37 -0800 Subject: [PATCH 1/9] feat(bazel): add `rtk bazel query` with tree-based package grouping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Bazel support to RTK with `rtk bazel query` for grouped, hierarchical target output. Strips stderr noise (INFO/WARNING/DEBUG), groups targets by package, and renders a depth/width-controlled tree with cumulative counts. Unsupported bazel subcommands pass through transparently. Output uses 📦 for sub-packages and 🎯 for targets, with a header showing total counts. Default --depth 1 --width 10 collapses large repos (e.g. 2,782 lines on the bazel repo) to ~8 top-level summary lines. --depth all --width all shows everything. - Limit type (number or "all") with FromStr for Clap - TreeNode with cumulative_targets/cumulative_packages for subtree counts - Width budget: sub-packages first, remaining slots for targets - Condensed truncation: (+N more sub-packages, M more targets) - detect_query_root extracts //path/... for scoped queries - Passthrough for all other bazel subcommands (build, test, etc.) - Exit code propagation, tee output recovery on failure - 24 tests covering depth, width, truncation, relative names, edge cases Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 1 + src/bazel_cmd.rs | 906 +++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 35 ++ 3 files changed, 942 insertions(+) create mode 100644 src/bazel_cmd.rs diff --git a/CLAUDE.md b/CLAUDE.md index 5fc190de..dd686825 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -208,6 +208,7 @@ rtk gain --history | grep proxy | Module | Purpose | Token Strategy | |--------|---------|----------------| +| bazel_cmd.rs | Bazel commands | Strip stderr noise, group targets by package (85% reduction) | | git.rs | Git operations | Stat summaries + compact diffs | | grep_cmd.rs | Code search | Group by file, truncate lines | | ls.rs | Directory listing | Tree format, aggregate counts | diff --git a/src/bazel_cmd.rs b/src/bazel_cmd.rs new file mode 100644 index 00000000..8ebecf7b --- /dev/null +++ b/src/bazel_cmd.rs @@ -0,0 +1,906 @@ +use crate::tracking; +use anyhow::{Context, Result}; +use lazy_static::lazy_static; +use regex::Regex; +use std::collections::BTreeMap; +use std::ffi::OsString; +use std::fmt; +use std::process::Command; +use std::str::FromStr; + +lazy_static! { + /// Matches timestamped INFO/WARNING/DEBUG lines from Bazel stderr + /// e.g. "(10:23:45) INFO: Build option..." + static ref NOISE_WITH_TIMESTAMP: Regex = + Regex::new(r"^\(\d+:\d+:\d+\)\s*(INFO|WARNING|DEBUG):").unwrap(); + + /// Matches plain INFO/WARNING/DEBUG lines without timestamp + static ref NOISE_PLAIN: Regex = + Regex::new(r"^(INFO|WARNING|DEBUG):").unwrap(); + + /// Matches ERROR lines (with or without timestamp) + static ref ERROR_WITH_TIMESTAMP: Regex = + Regex::new(r"^\(\d+:\d+:\d+\)\s*ERROR:").unwrap(); + static ref ERROR_PLAIN: Regex = + Regex::new(r"^ERROR:").unwrap(); + + /// Matches Bazel target lines like //package/path:target_name or //:root_target + static ref TARGET_LINE: Regex = + Regex::new(r"^(//[^:]*):(.+)$").unwrap(); +} + +/// A limit value that can be a specific number or unlimited ("all"). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Limit { + N(usize), + All, +} + +impl Limit { + pub fn value(&self) -> usize { + match self { + Limit::N(n) => *n, + Limit::All => usize::MAX, + } + } +} + +impl FromStr for Limit { + type Err = String; + + fn from_str(s: &str) -> std::result::Result { + if s.eq_ignore_ascii_case("all") { + Ok(Limit::All) + } else { + s.parse::() + .map(Limit::N) + .map_err(|_| format!("expected a number or 'all', got '{}'", s)) + } + } +} + +impl fmt::Display for Limit { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Limit::N(n) => write!(f, "{}", n), + Limit::All => write!(f, "all"), + } + } +} + +/// Detect the query root from args. +/// Scans for the first `//path/...` pattern and returns `(display_expr, root_path)`. +/// Fallback: `("//...", "//")`. +fn detect_query_root(args: &[String]) -> (String, String) { + for arg in args { + let trimmed = arg.trim_matches('\'').trim_matches('"'); + if trimmed.contains("//") && trimmed.contains("...") { + let root = trimmed.trim_end_matches("..."); + let root = root.trim_end_matches('/'); + let root = if root.is_empty() { "//" } else { root }; + return (trimmed.to_string(), root.to_string()); + } + } + ("//...".to_string(), "//".to_string()) +} + +/// Count path components of a package relative to a root. +/// root="//" package="//src/lib/foo" → 3 (src, lib, foo) +/// root="//src" package="//src/lib/foo" → 2 (lib, foo) +#[cfg(test)] +fn package_depth(root: &str, package: &str) -> usize { + let root_stripped = root.strip_prefix("//").unwrap_or(root); + let pkg_stripped = package.strip_prefix("//").unwrap_or(package); + + let relative = if root_stripped.is_empty() { + pkg_stripped + } else { + pkg_stripped + .strip_prefix(root_stripped) + .unwrap_or(pkg_stripped) + .strip_prefix('/') + .unwrap_or("") + }; + + if relative.is_empty() { + 0 + } else { + relative.split('/').count() + } +} + +/// Extract the relative name of a child package under a parent. +/// parent="//examples", child="//examples/cpp" → "cpp" +/// parent="//", child="//src" → "src" +#[cfg(test)] +fn relative_name(parent: &str, child: &str) -> String { + let parent_stripped = parent.strip_prefix("//").unwrap_or(parent); + let child_stripped = child.strip_prefix("//").unwrap_or(child); + + if parent_stripped.is_empty() { + // root parent, take first component + child_stripped.split('/').next().unwrap_or("").to_string() + } else { + child_stripped + .strip_prefix(parent_stripped) + .unwrap_or(child_stripped) + .strip_prefix('/') + .unwrap_or("") + .split('/') + .next() + .unwrap_or("") + .to_string() + } +} + +/// A node in the package tree for hierarchical rendering. +#[derive(Debug, Default)] +struct TreeNode { + /// Targets directly in this package + targets: Vec, + /// Child package nodes, keyed by their relative name + children: BTreeMap, +} + +impl TreeNode { + /// Count cumulative targets in entire subtree (including self). + fn cumulative_targets(&self) -> usize { + self.targets.len() + + self + .children + .values() + .map(|c| c.cumulative_targets()) + .sum::() + } + + /// Count cumulative sub-packages in entire subtree (not including self). + fn cumulative_packages(&self) -> usize { + let direct = self.children.len(); + direct + + self + .children + .values() + .map(|c| c.cumulative_packages()) + .sum::() + } +} + +/// Build a tree from a flat BTreeMap of packages under a given root. +fn build_tree(packages: &BTreeMap>, root: &str) -> TreeNode { + let mut tree = TreeNode::default(); + + // Add root's own targets if present + if let Some(targets) = packages.get(root) { + tree.targets = targets.clone(); + } + + // Collect all packages under this root (excluding the root itself) + let root_stripped = root.strip_prefix("//").unwrap_or(root); + + for (pkg, targets) in packages { + let pkg_stripped = pkg.strip_prefix("//").unwrap_or(pkg); + + // Skip the root package itself + if pkg_stripped == root_stripped { + continue; + } + + // Check if this package is under the root + let relative = if root_stripped.is_empty() { + if pkg_stripped.is_empty() { + continue; + } + pkg_stripped.to_string() + } else if let Some(rest) = pkg_stripped.strip_prefix(root_stripped) { + if let Some(rest) = rest.strip_prefix('/') { + rest.to_string() + } else { + continue; + } + } else { + continue; + }; + + // Walk the path components and insert into tree + let parts: Vec<&str> = relative.split('/').collect(); + let mut current = &mut tree; + + for part in &parts { + current = current.children.entry(part.to_string()).or_default(); + } + + // Set targets on the leaf node + current.targets = targets.clone(); + } + + tree +} + +/// Format a count label like "5 targets" or "1 target", with optional package count. +fn format_counts(target_count: usize, package_count: usize) -> String { + let mut parts = Vec::new(); + + if target_count > 0 { + let label = if target_count == 1 { + "target" + } else { + "targets" + }; + parts.push(format!("{} {}", target_count, label)); + } + + if package_count > 0 { + let label = if package_count == 1 { + "package" + } else { + "packages" + }; + parts.push(format!("{} {}", package_count, label)); + } + + if parts.is_empty() { + "0 targets".to_string() + } else { + parts.join(", ") + } +} + +/// Render a tree node's children at a given depth, with indentation. +fn render_tree( + node: &TreeNode, + max_depth: usize, + width: usize, + current_depth: usize, + result: &mut String, +) { + if current_depth >= max_depth { + return; + } + + let indent = " ".repeat(current_depth); + + let child_count = node.children.len(); + let target_count = node.targets.len(); + + // Width budget: sub-packages first, then targets + let pkg_slots = width.min(child_count); + let remaining_slots = width.saturating_sub(pkg_slots); + let target_slots = remaining_slots.min(target_count); + + let hidden_packages = child_count.saturating_sub(pkg_slots); + let hidden_targets = target_count.saturating_sub(target_slots); + + // Render sub-packages + for (i, (name, child)) in node.children.iter().enumerate() { + if i >= pkg_slots { + break; + } + let cum_targets = child.cumulative_targets(); + let cum_packages = child.cumulative_packages(); + let counts = format_counts(cum_targets, cum_packages); + result.push_str(&format!("{}📦 {} ({})\n", indent, name, counts)); + + // Recurse into child if within depth + render_tree(child, max_depth, width, current_depth + 1, result); + } + + // Render targets + for (i, target) in node.targets.iter().enumerate() { + if i >= target_slots { + break; + } + result.push_str(&format!("{}🎯 :{}\n", indent, target)); + } + + // Truncation line + if hidden_packages > 0 || hidden_targets > 0 { + let mut parts = Vec::new(); + if hidden_packages > 0 { + parts.push(format!( + "{} more sub-package{}", + hidden_packages, + if hidden_packages == 1 { "" } else { "s" } + )); + } + if hidden_targets > 0 { + parts.push(format!( + "{} more target{}", + hidden_targets, + if hidden_targets == 1 { "" } else { "s" } + )); + } + result.push_str(&format!("{}(+{})\n", indent, parts.join(", "))); + } +} + +/// Filter bazel query output with depth/width controls. +/// +/// - `depth`: how many levels deep to show (usize::MAX for unlimited) +/// - `width`: max items per level (usize::MAX for unlimited) +/// - `root`: (display_expr, root_path) from detect_query_root +pub fn filter_bazel_query( + stdout: &str, + stderr: &str, + depth: usize, + width: usize, + root: &(String, String), +) -> String { + let mut result = String::new(); + + // Collect ERROR lines from stderr + for line in stderr.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + if ERROR_WITH_TIMESTAMP.is_match(trimmed) || ERROR_PLAIN.is_match(trimmed) { + result.push_str(trimmed); + result.push('\n'); + } + } + + // Group targets by package, preserve non-target lines + let mut packages: BTreeMap> = BTreeMap::new(); + let mut non_target_lines: Vec = Vec::new(); + + for line in stdout.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + if let Some(caps) = TARGET_LINE.captures(trimmed) { + let package = caps[1].to_string(); + let target = caps[2].to_string(); + packages.entry(package).or_default().push(target); + } else { + non_target_lines.push(trimmed.to_string()); + } + } + + let (display_expr, root_path) = root; + let tree = build_tree(&packages, root_path); + + let total_targets = tree.cumulative_targets(); + let total_packages = tree.cumulative_packages(); + let counts = format_counts(total_targets, total_packages); + + // Header line (no emoji) + result.push_str(&format!("{} ({})\n", display_expr, counts)); + + // Render children + render_tree(&tree, depth, width, 0, &mut result); + + // Output non-target lines + for line in &non_target_lines { + result.push_str(line); + result.push('\n'); + } + + result.trim_end().to_string() +} + +pub fn run_query(args: &[String], depth: Limit, width: Limit, verbose: u8) -> Result<()> { + let timer = tracking::TimedExecution::start(); + + let root = detect_query_root(args); + + let mut cmd = Command::new("bazel"); + cmd.arg("query"); + + for arg in args { + cmd.arg(arg); + } + + if verbose > 0 { + eprintln!("Running: bazel query {}", args.join(" ")); + } + + let output = cmd + .output() + .context("Failed to run bazel query. Is Bazel 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_bazel_query(&stdout, &stderr, depth.value(), width.value(), &root); + + if let Some(hint) = crate::tee::tee_and_hint(&raw, "bazel_query", exit_code) { + println!("{}\n{}", filtered, hint); + } else { + println!("{}", filtered); + } + + timer.track( + &format!("bazel query {}", args.join(" ")), + &format!("rtk bazel query {}", 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!("bazel: no subcommand specified"); + } + + let timer = tracking::TimedExecution::start(); + + let subcommand = args[0].to_string_lossy(); + let mut cmd = Command::new("bazel"); + cmd.arg(&*subcommand); + + for arg in &args[1..] { + cmd.arg(arg); + } + + if verbose > 0 { + eprintln!("Running: bazel {} ...", subcommand); + } + + let output = cmd + .output() + .with_context(|| format!("Failed to run bazel {}", 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!("bazel {}", subcommand), + &format!("rtk bazel {}", subcommand), + &raw, + &raw, // No filtering for unsupported commands + ); + + if !output.status.success() { + std::process::exit(output.status.code().unwrap_or(1)); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn default_root() -> (String, String) { + ("//...".to_string(), "//".to_string()) + } + + fn query(stdout: &str, stderr: &str, depth: usize, width: usize) -> String { + filter_bazel_query(stdout, stderr, depth, width, &default_root()) + } + + // === Limit type tests === + + #[test] + fn test_limit_from_str() { + assert_eq!("1".parse::().unwrap(), Limit::N(1)); + assert_eq!("10".parse::().unwrap(), Limit::N(10)); + assert_eq!("0".parse::().unwrap(), Limit::N(0)); + assert_eq!("all".parse::().unwrap(), Limit::All); + assert_eq!("ALL".parse::().unwrap(), Limit::All); + assert_eq!("All".parse::().unwrap(), Limit::All); + assert!("invalid".parse::().is_err()); + assert!("".parse::().is_err()); + } + + #[test] + fn test_limit_value() { + assert_eq!(Limit::N(5).value(), 5); + assert_eq!(Limit::All.value(), usize::MAX); + } + + #[test] + fn test_limit_display() { + assert_eq!(Limit::N(5).to_string(), "5"); + assert_eq!(Limit::All.to_string(), "all"); + } + + // === Helper function tests === + + #[test] + fn test_detect_query_root() { + // Single //... + let root = detect_query_root(&["//...".to_string()]); + assert_eq!(root, ("//...".to_string(), "//".to_string())); + + // Subpath + let root = detect_query_root(&["//examples/...".to_string()]); + assert_eq!( + root, + ("//examples/...".to_string(), "//examples".to_string()) + ); + + // Quoted args + let root = detect_query_root(&["'//src/...'".to_string()]); + assert_eq!(root, ("//src/...".to_string(), "//src".to_string())); + + // No match → fallback + let root = detect_query_root(&["--keep_going".to_string()]); + assert_eq!(root, ("//...".to_string(), "//".to_string())); + + // Multiple args → takes first match + let root = detect_query_root(&["--keep_going".to_string(), "//host/...".to_string()]); + assert_eq!(root, ("//host/...".to_string(), "//host".to_string())); + } + + #[test] + fn test_package_depth() { + assert_eq!(package_depth("//", "//src"), 1); + assert_eq!(package_depth("//", "//src/lib"), 2); + assert_eq!(package_depth("//", "//src/lib/foo"), 3); + assert_eq!(package_depth("//", "//"), 0); + assert_eq!(package_depth("//src", "//src"), 0); + assert_eq!(package_depth("//src", "//src/lib"), 1); + assert_eq!(package_depth("//src", "//src/lib/foo"), 2); + } + + #[test] + fn test_relative_name() { + assert_eq!(relative_name("//", "//src"), "src"); + assert_eq!(relative_name("//", "//src/lib"), "src"); + assert_eq!(relative_name("//examples", "//examples/cpp"), "cpp"); + assert_eq!( + relative_name("//examples", "//examples/java-native"), + "java-native" + ); + } + + // === Core filter tests === + + #[test] + fn test_strips_info_warning_noise() { + let stderr = "\ +(10:23:45) INFO: Invocation ID: abc-123 +(10:23:45) INFO: Build options changed +(10:23:46) WARNING: some warning +(10:23:47) DEBUG: debug info +INFO: plain info line +WARNING: plain warning +DEBUG: plain debug"; + let stdout = "//pkg:target"; + let result = query(stdout, stderr, usize::MAX, usize::MAX); + + assert!(!result.contains("Invocation ID")); + assert!(!result.contains("Build options changed")); + assert!(!result.contains("some warning")); + assert!(!result.contains("debug info")); + assert!(!result.contains("plain info line")); + assert!(!result.contains("plain warning")); + assert!(!result.contains("plain debug")); + assert!(result.contains("🎯 :target")); + } + + #[test] + fn test_keeps_error_lines() { + let stderr = "\ +(10:23:45) INFO: Build options changed +(10:23:46) ERROR: something went wrong +ERROR: another error"; + let stdout = "//pkg:target"; + let result = query(stdout, stderr, usize::MAX, usize::MAX); + + assert!(result.contains("ERROR: something went wrong")); + assert!(result.contains("ERROR: another error")); + assert!(!result.contains("Build options changed")); + } + + #[test] + fn test_empty_output() { + let result = query("", "", usize::MAX, usize::MAX); + // With default root, header is still produced + assert!(result.contains("//... (0 targets)")); + } + + #[test] + fn test_non_target_lines_pass_through() { + let stdout = "\ +//pkg:target_a +some non-target output line +//:root_target"; + let result = query(stdout, "", usize::MAX, usize::MAX); + + assert!(result.contains("some non-target output line")); + assert!(result.contains("🎯 :target_a")); + assert!(result.contains("🎯 :root_target")); + } + + #[test] + fn test_single_target_uses_singular() { + let stdout = "//my/package:only_target"; + let result = query(stdout, "", usize::MAX, usize::MAX); + assert!(result.contains("(1 target)")); + } + + // === Header line tests === + + #[test] + fn test_header_line() { + let stdout = "\ +//src/lib:a +//src/lib:b +//tools:c"; + let result = query(stdout, "", usize::MAX, usize::MAX); + + // Header has cumulative totals, no emoji + assert!(result.starts_with("//... (3 targets, 3 packages)")); + } + + // === Depth tests === + + #[test] + fn test_depth_1_collapses_to_summary() { + let stdout = "\ +//src/lib:a +//src/lib:b +//src/app:c +//tools/gen:d +//tools/gen:e +//tools/gen:f +//:root_target"; + let result = query(stdout, "", 1, usize::MAX); + + // Depth 1: should show src, tools as 📦 with cumulative counts + assert!(result.contains("📦 src (3 targets, 2 packages)")); + assert!(result.contains("📦 tools (3 targets, 1 package)")); + // Root target shown as 🎯 + assert!(result.contains("🎯 :root_target")); + // Should NOT show children (lib, app, gen) + assert!(!result.contains("📦 lib")); + assert!(!result.contains("📦 app")); + assert!(!result.contains("📦 gen")); + } + + #[test] + fn test_depth_2_shows_two_levels() { + let stdout = "\ +//src/lib/math:a +//src/lib/math:b +//src/lib/io:c +//src/app:d +//tools:e"; + let result = query(stdout, "", 2, usize::MAX); + + // Level 1: src, tools visible + assert!(result.contains("📦 src (4 targets, 4 packages)")); + assert!(result.contains("📦 tools (1 target)")); + // Level 2: lib and app visible under src with relative names + assert!(result.contains(" 📦 lib (3 targets, 2 packages)")); + assert!(result.contains(" 📦 app (1 target)")); + // Level 3 (math, io) NOT expanded + assert!(!result.contains(" 📦 math")); + assert!(!result.contains(" 📦 io")); + } + + #[test] + fn test_depth_all_shows_everything() { + let stdout = "\ +//src/lib/math:a +//src/lib/io:b +//src/app:c"; + let result = query(stdout, "", usize::MAX, usize::MAX); + + // All levels visible + assert!(result.contains("📦 src")); + assert!(result.contains(" 📦 lib")); + assert!(result.contains(" 📦 math")); + assert!(result.contains(" 📦 io")); + assert!(result.contains(" 📦 app")); + // Leaf targets shown + assert!(result.contains(" 🎯 :a")); + assert!(result.contains(" 🎯 :b")); + assert!(result.contains(" 🎯 :c")); + } + + #[test] + fn test_always_cumulative_counts() { + // Even when expanded, parent shows full subtree count + let stdout = "\ +//examples/cpp:a +//examples/cpp:b +//examples/go:c +//examples/java/sub:d"; + let result = query(stdout, "", 2, usize::MAX); + + // examples shows cumulative: 4 targets, 4 packages (cpp, go, java, sub) + // Note: java/sub is counted as an additional package node in the tree + assert!(result.contains("📦 examples (4 targets, 4 packages)")); + // Children are expanded but parent still shows full counts + assert!(result.contains(" 📦 cpp (2 targets)")); + assert!(result.contains(" 📦 go (1 target)")); + assert!(result.contains(" 📦 java (1 target, 1 package)")); + } + + // === Width tests === + + #[test] + fn test_width_budget_packages_then_targets() { + // Width 5: 3 sub-packages take 3 slots, 2 remaining for targets + let stdout = "\ +//src:a +//src:b +//src:c +//src:d +//tools:e +//lib:f +//:root_a +//:root_b +//:root_c"; + let result = query(stdout, "", 1, 5); + + // 3 sub-packages use 3 slots + assert!(result.contains("📦 lib")); + assert!(result.contains("📦 src")); + assert!(result.contains("📦 tools")); + // 2 remaining slots for targets + assert!(result.contains("🎯 :root_a")); + assert!(result.contains("🎯 :root_b")); + // Third target hidden + assert!(!result.contains("🎯 :root_c")); + assert!(result.contains("(+1 more target)")); + } + + #[test] + fn test_width_limits_packages() { + let stdout = "\ +//a:t1 +//b:t2 +//c:t3 +//d:t4 +//e:t5"; + let result = query(stdout, "", 1, 3); + + // Only 3 packages shown (BTreeMap order: a, b, c) + assert!(result.contains("📦 a")); + assert!(result.contains("📦 b")); + assert!(result.contains("📦 c")); + assert!(!result.contains("📦 d")); + assert!(!result.contains("📦 e")); + assert!(result.contains("(+2 more sub-packages)")); + } + + #[test] + fn test_condensed_truncation_line() { + // Both packages and targets truncated + let stdout = "\ +//a:t +//b:t +//c:t +//d:t +//:x +//:y +//:z"; + let result = query(stdout, "", 1, 3); + + // 3 width: 3 packages shown (a, b, c), d hidden, targets use 0 slots + // All 3 root targets hidden + assert!(result.contains("(+1 more sub-package, 3 more targets)")); + } + + #[test] + fn test_condensed_truncation_omits_zero_parts() { + // Only packages truncated, no targets + let stdout = "\ +//a:t +//b:t +//c:t +//d:t"; + let result = query(stdout, "", 1, 3); + + // 3 packages shown, 1 hidden, no root targets + assert!(result.contains("(+1 more sub-package)")); + assert!(!result.contains("more target")); + } + + // === Root target tests === + + #[test] + fn test_root_targets_inline() { + let stdout = "\ +//:bazel-distfile +//:bazel-srcs +//src:lib"; + let result = query(stdout, "", 1, usize::MAX); + + // Root targets shown as 🎯 at top level + assert!(result.contains("🎯 :bazel-distfile")); + assert!(result.contains("🎯 :bazel-srcs")); + // Sub-package shown as 📦 + assert!(result.contains("📦 src")); + } + + // === Relative names tests === + + #[test] + fn test_relative_names() { + let stdout = "\ +//examples/cpp:a +//examples/go:b"; + let result = query(stdout, "", 2, usize::MAX); + + // Children show relative names (cpp, go), not full path + assert!(result.contains(" 📦 cpp")); + assert!(result.contains(" 📦 go")); + assert!(!result.contains("examples/cpp")); + assert!(!result.contains("examples/go")); + } + + // === Multi-root tests === + + // === Backward-compatible tests (ported from old tests) === + + #[test] + fn test_groups_targets_by_package() { + let stdout = "\ +//src/lib/math/compute:target_a +//src/lib/math/compute:target_b +//src/lib/math/compute:target_c +//tools/codegen:foo +//tools/codegen:bar"; + let result = query(stdout, "", usize::MAX, usize::MAX); + + // With full depth, targets are at leaf nodes + assert!(result.contains("🎯 :target_a")); + assert!(result.contains("🎯 :target_b")); + assert!(result.contains("🎯 :target_c")); + assert!(result.contains("🎯 :foo")); + assert!(result.contains("🎯 :bar")); + } + + #[test] + fn test_real_bazel_output() { + let stderr = "\ +(10:23:45) INFO: Invocation ID: 8e2f4a91-abc1-4def-9012-345678abcdef +(10:23:45) INFO: Current date is 2026-03-01 +(10:23:46) WARNING: Build option --config=remote has changed +(10:23:46) INFO: Repository rule @bazel_tools//tools/jdk:jdk configured +(10:23:47) INFO: Found 16 targets... +(10:23:47) INFO: Elapsed time: 1.234s"; + let stdout = "\ +//src/app/foo/bar:bar +//src/app/foo/bar:bar_test +//src/app/foo/bar:bar_lib +//src/app/foo/bar:config +//src/app/foo/bar:config_test +//src/app/foo/bar:utils +//src/app/foo/bar:utils_test +//src/app/foo/bar:integration_test +//src/app/foo/bar:benchmark +//src/app/foo/bar:benchmark_lib +//src/app/foo/bar:data +//src/app/foo/bar:test_data +//src/app/foo/bar:model +//src/app/foo/bar:model_test +//src/app/foo/bar:runner +//src/app/foo/bar:runner_test"; + + let result = query(stdout, stderr, usize::MAX, usize::MAX); + + // Should strip all INFO/WARNING noise + assert!(!result.contains("Invocation ID")); + assert!(!result.contains("Elapsed time")); + + // Header with total count + assert!(result.contains("//... (16 targets, 4 packages)")); + + // All 16 targets should be present (depth=all) + assert!(result.contains("🎯 :bar\n")); + assert!(result.contains("🎯 :runner_test")); + } +} diff --git a/src/main.rs b/src/main.rs index 2e80c60b..443feffb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ mod aws_cmd; +mod bazel_cmd; mod cargo_cmd; mod cc_economics; mod ccusage; @@ -471,6 +472,12 @@ enum Commands { args: Vec, }, + /// Bazel commands with compact output + Bazel { + #[command(subcommand)] + command: BazelCommands, + }, + /// Cargo commands with compact output Cargo { #[command(subcommand)] @@ -903,6 +910,25 @@ enum CargoCommands { Other(Vec), } +#[derive(Subcommand)] +enum BazelCommands { + /// Query with grouped target output (85% token reduction) + Query { + /// Maximum depth of package tree to show (default: 1, "all" for unlimited) + #[arg(long, default_value = "1")] + depth: bazel_cmd::Limit, + /// Maximum items per level (default: 10, "all" for unlimited) + #[arg(long, default_value = "10")] + width: bazel_cmd::Limit, + /// Additional bazel query arguments + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Passthrough: runs any unsupported bazel subcommand directly + #[command(external_subcommand)] + Other(Vec), +} + #[derive(Subcommand)] enum GoCommands { /// Run tests with compact output (90% token reduction via JSON streaming) @@ -1516,6 +1542,15 @@ fn main() -> Result<()> { playwright_cmd::run(&args, cli.verbose)?; } + Commands::Bazel { command } => match command { + BazelCommands::Query { depth, width, args } => { + bazel_cmd::run_query(&args, depth, width, cli.verbose)?; + } + BazelCommands::Other(args) => { + bazel_cmd::run_other(&args, cli.verbose)?; + } + }, + Commands::Cargo { command } => match command { CargoCommands::Build { args } => { cargo_cmd::run(cargo_cmd::CargoCommand::Build, &args, cli.verbose)?; From 527ae2e96234fe7fc54c74fa2619aa2a2099b9c4 Mon Sep 17 00:00:00 2001 From: cmolder <28611108+cmolder@users.noreply.github.com> Date: Mon, 2 Mar 2026 15:14:34 -0800 Subject: [PATCH 2/9] feat(bazel): add `rtk bazel build` with error/warning-only output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strip progress lines, INFO/DEBUG noise, Loading/Analyzing status, Java notes, and target output paths. Keep ERROR/WARNING lines and compiler diagnostics (gcc/clang warning:/error: blocks with context). Success: "✓ bazel build (N actions)" Issues: header + error/warning blocks, truncated at 15 10 new tests including token savings verification (≥60%). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/bazel_cmd.rs | 698 ++++++++++++++++++++++++++++++++++++++++++++--- src/main.rs | 9 + 2 files changed, 667 insertions(+), 40 deletions(-) diff --git a/src/bazel_cmd.rs b/src/bazel_cmd.rs index 8ebecf7b..55e50de5 100644 --- a/src/bazel_cmd.rs +++ b/src/bazel_cmd.rs @@ -8,25 +8,64 @@ use std::fmt; use std::process::Command; use std::str::FromStr; +/**********************************************************************/ +/* Shared Bazel Utilities */ +/**********************************************************************/ + lazy_static! { - /// Matches timestamped INFO/WARNING/DEBUG lines from Bazel stderr + /// Matches Bazel INFO, WARNING, and DEBUG lines + /// + /// e.g. "INFO: Build option..." + static ref NOISE_PLAIN: Regex = + Regex::new(r"^(INFO|WARNING|DEBUG):").unwrap(); + + /// Matches Bazel INFO, WARNING, and DEBUG lines + /// /// e.g. "(10:23:45) INFO: Build option..." static ref NOISE_WITH_TIMESTAMP: Regex = Regex::new(r"^\(\d+:\d+:\d+\)\s*(INFO|WARNING|DEBUG):").unwrap(); - /// Matches plain INFO/WARNING/DEBUG lines without timestamp - static ref NOISE_PLAIN: Regex = - Regex::new(r"^(INFO|WARNING|DEBUG):").unwrap(); + /// Matches Bazel ERROR lines without timestamp + /// + /// e.g. "ERROR: Compilation failed..." + static ref ERROR_PLAIN: Regex = + Regex::new(r"^ERROR:").unwrap(); - /// Matches ERROR lines (with or without timestamp) + /// Matches Bazel ERROR lines with timestamp + /// + /// e.g. "(10:23:45) ERROR: Compilation failed..." static ref ERROR_WITH_TIMESTAMP: Regex = Regex::new(r"^\(\d+:\d+:\d+\)\s*ERROR:").unwrap(); - static ref ERROR_PLAIN: Regex = - Regex::new(r"^ERROR:").unwrap(); - /// Matches Bazel target lines like //package/path:target_name or //:root_target + /// Matches Bazel target lines + /// + /// e.g. "//package/path:target_name", "//:root_target" static ref TARGET_LINE: Regex = Regex::new(r"^(//[^:]*):(.+)$").unwrap(); + + /// Matches Bazel progress lines + /// + /// e.g. "[123 / 4,567] Progress message..." + static ref PROGRESS_LINE: Regex = + Regex::new(r"^\[[\d,]+ / [\d,]+\]").unwrap(); + + /// Matches INFO lines with action counts + /// + /// e.g. "123 total actions" + static ref ACTION_COUNT: Regex = + Regex::new(r"(\d[\d,]*)\s+total actions").unwrap(); + + /// Matches WARNING lines without timestamp + /// + /// e.g. "WARNING: Warning message..." + static ref WARNING_PLAIN: Regex = + Regex::new(r"^WARNING:").unwrap(); + + /// Matches WARNING lines with timestamp + /// + /// e.g. "(10:23:45) WARNING: Warning message..." + static ref WARNING_WITH_TIMESTAMP: Regex = + Regex::new(r"^\(\d+:\d+:\d+\)\s*WARNING:").unwrap(); } /// A limit value that can be a specific number or unlimited ("all"). @@ -68,6 +107,287 @@ impl fmt::Display for Limit { } } +/**********************************************************************/ +/* bazel build */ +/**********************************************************************/ + +/// Filter `bazel build` output. +/// +/// # Arguments +/// +/// * `stdout` - stdout output from `bazel build` +/// * `stderr` - stderr output from `bazel build` +/// +/// # Returns +/// +/// The filtered `bazel build` output +/// +/// # Notes +/// +/// Strips progress and info noise, while keeping errors and warnings. +/// Bazel sends most output to stderr. This function reads the combined +/// stdout and stderr stream and filters the following: +/// * Progress lines `[N / M]` +/// * Loading/Analyzing +/// * INFO +/// * Note +/// * Target/bazel-bin output paths +/// +/// Meanwhile, the following lines are kept: +/// * ERROR lines +/// * WARNING lines +/// * Build diagnostics (e.g. warnings/errors from gcc/clang) +/// +pub fn filter_bazel_build(stdout: &str, stderr: &str) -> String { + let mut errors: Vec = Vec::new(); + let mut warnings: Vec = Vec::new(); + let mut error_count: usize = 0; + let mut warning_count: usize = 0; + let mut action_count: Option = None; + + // State for collecting multi-line compiler diagnostic blocks + let mut in_diagnostic = false; + let mut current_block: Vec = Vec::new(); + let mut current_is_error = false; + + // Combine stdout and stderr (bazel sends most to stderr) + let combined = format!("{}\n{}", stderr, stdout); + + for line in combined.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + // Blank line ends a diagnostic block + if in_diagnostic && !current_block.is_empty() { + if current_is_error { + errors.push(current_block.join("\n")); + } else { + warnings.push(current_block.join("\n")); + } + current_block.clear(); + in_diagnostic = false; + } + continue; + } + + // Extract action count from INFO lines before skipping them + if trimmed.starts_with("INFO:") || NOISE_WITH_TIMESTAMP.is_match(trimmed) { + if let Some(caps) = ACTION_COUNT.captures(trimmed) { + action_count = Some(caps[1].to_string()); + } + // "INFO: From ..." lines precede compiler output — skip the INFO line itself + // but don't skip the following compiler diagnostic lines + continue; + } + + // Strip progress lines: [N / M] ... + if PROGRESS_LINE.is_match(trimmed) { + continue; + } + + // Strip loading/analyzing status + if trimmed.starts_with("Loading:") + || trimmed.starts_with("Analyzing:") + || trimmed.starts_with("Computing main repo mapping:") + { + continue; + } + + // Strip Java notes + if trimmed.starts_with("Note: ") { + continue; + } + + // Strip target output paths + if trimmed.starts_with("Target //") || trimmed.starts_with("bazel-bin/") { + continue; + } + + // Strip DEBUG lines + if trimmed.starts_with("DEBUG:") { + continue; + } + + // Bazel-level ERROR lines + if ERROR_PLAIN.is_match(trimmed) || ERROR_WITH_TIMESTAMP.is_match(trimmed) { + // Flush any in-progress diagnostic block + if in_diagnostic && !current_block.is_empty() { + if current_is_error { + errors.push(current_block.join("\n")); + } else { + warnings.push(current_block.join("\n")); + } + current_block.clear(); + in_diagnostic = false; + } + // Skip the summary "Build did NOT complete successfully" — we show our own header + if trimmed.contains("Build did NOT complete successfully") { + error_count = error_count.max(1); // ensure we show error header + continue; + } + error_count += 1; + errors.push(trimmed.to_string()); + continue; + } + + // Bazel-level WARNING lines + if WARNING_PLAIN.is_match(trimmed) || WARNING_WITH_TIMESTAMP.is_match(trimmed) { + // Flush any in-progress diagnostic block + if in_diagnostic && !current_block.is_empty() { + if current_is_error { + errors.push(current_block.join("\n")); + } else { + warnings.push(current_block.join("\n")); + } + current_block.clear(); + in_diagnostic = false; + } + warning_count += 1; + warnings.push(trimmed.to_string()); + continue; + } + + // Compiler diagnostic: "file:line:col: warning: ..." or "file:line:col: error: ..." + // These come from gcc/clang output embedded in bazel stderr + if trimmed.contains(": warning:") || trimmed.contains(": error:") { + // Flush previous block if any + if in_diagnostic && !current_block.is_empty() { + if current_is_error { + errors.push(current_block.join("\n")); + } else { + warnings.push(current_block.join("\n")); + } + current_block.clear(); + } + current_is_error = trimmed.contains(": error:"); + if current_is_error { + error_count += 1; + } else { + warning_count += 1; + } + in_diagnostic = true; + current_block.push(trimmed.to_string()); + continue; + } + + // Continuation of a compiler diagnostic block (source context, notes, etc.) + if in_diagnostic { + // Lines with ` | `, `note:`, source locations, or caret lines are context + current_block.push(trimmed.to_string()); + continue; + } + + // Anything else that doesn't match known noise — skip + // (indented bazel-bin paths, etc.) + } + + // Flush final block + if in_diagnostic && !current_block.is_empty() { + if current_is_error { + errors.push(current_block.join("\n")); + } else { + warnings.push(current_block.join("\n")); + } + } + + let actions_str = action_count.unwrap_or_else(|| "0".to_string()); + + // No errors or warnings: one-liner success + if error_count == 0 && warning_count == 0 { + return format!("✓ bazel build ({} actions)", actions_str); + } + + // Format with header + blocks + let mut result = String::new(); + result.push_str(&format!( + "bazel build: {} error{}, {} warning{} ({} actions)\n", + error_count, + if error_count == 1 { "" } else { "s" }, + warning_count, + if warning_count == 1 { "" } else { "s" }, + actions_str, + )); + result.push_str("═══════════════════════════════════════\n"); + + // Show errors first, then warnings + let all_blocks: Vec<&String> = errors.iter().chain(warnings.iter()).collect(); + for (i, block) in all_blocks.iter().enumerate().take(15) { + result.push_str(block); + result.push('\n'); + if i < all_blocks.len().min(15) - 1 { + result.push('\n'); + } + } + + if all_blocks.len() > 15 { + result.push_str(&format!("\n... +{} more issues\n", all_blocks.len() - 15)); + } + + result.trim().to_string() +} + +/// Run `bazel build` while filtering the output. +/// +/// # Arguments +/// +/// * `args` - Arguments to pass to `bazel build` +/// * `verbose` - Verbosity level +/// +/// # Returns +/// +/// Result of the operation +/// +pub fn run_build(args: &[String], verbose: u8) -> Result<()> { + let timer = tracking::TimedExecution::start(); + + let mut cmd = Command::new("bazel"); + cmd.arg("build"); + + for arg in args { + cmd.arg(arg); + } + + if verbose > 0 { + eprintln!("Running: bazel build {}", args.join(" ")); + } + + let output = cmd + .output() + .context("Failed to run bazel build. Is Bazel 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_bazel_build(&stdout, &stderr); + + if let Some(hint) = crate::tee::tee_and_hint(&raw, "bazel_build", exit_code) { + println!("{}\n{}", filtered, hint); + } else { + println!("{}", filtered); + } + + timer.track( + &format!("bazel build {}", args.join(" ")), + &format!("rtk bazel build {}", args.join(" ")), + &raw, + &filtered, + ); + + if !output.status.success() { + std::process::exit(exit_code); + } + + Ok(()) +} + +/**********************************************************************/ +/* bazel query */ +/**********************************************************************/ + /// Detect the query root from args. /// Scans for the first `//path/...` pattern and returns `(display_expr, root_path)`. /// Fallback: `("//...", "//")`. @@ -313,11 +633,25 @@ fn render_tree( } } -/// Filter bazel query output with depth/width controls. +/// Filter `bazel query` output. +/// +/// # Arguments +/// +/// * `stdout` - stdout output from `bazel query` +/// * `stderr` - stderr output from `bazel query` +/// * `depth` - Maxmimum depth of the package tree to show +/// * `width` - Maximum number of items to show for each package +/// * `root` - (`display_expr`, `root_path`) from detect_query_root +/// +/// # Returns +/// +/// The filtered `bazel query` output +/// +/// # Notes +/// +/// * `depth` and `width` can be set to [`usize::MAX`] to disable +/// truncation. /// -/// - `depth`: how many levels deep to show (usize::MAX for unlimited) -/// - `width`: max items per level (usize::MAX for unlimited) -/// - `root`: (display_expr, root_path) from detect_query_root pub fn filter_bazel_query( stdout: &str, stderr: &str, @@ -380,6 +714,19 @@ pub fn filter_bazel_query( result.trim_end().to_string() } +/// Run `bazel query` while filtering the output. +/// +/// # Arguments +/// +/// * `args` - Arguments to pass to `bazel query` +/// * `depth` - Maximum depth of the package tree to show +/// * `width` - Maximum number of items to show for each package +/// * `verbose` - Verbosity level +/// +/// # Returns +/// +/// Result of the operation +/// pub fn run_query(args: &[String], depth: Limit, width: Limit, verbose: u8) -> Result<()> { let timer = tracking::TimedExecution::start(); @@ -430,6 +777,21 @@ pub fn run_query(args: &[String], depth: Limit, width: Limit, verbose: u8) -> Re Ok(()) } +/**********************************************************************/ +/* Other bazel subcommands */ +/**********************************************************************/ + +/// Run other `bazel` subcommands not handled by rtk. +/// +/// # Arguments +/// +/// * `args` - Arguments to pass to the `bazel` subcommand +/// * `verbose` - Verbosity level +/// +/// # Returns +/// +/// Result of the operation +/// pub fn run_other(args: &[OsString], verbose: u8) -> Result<()> { if args.is_empty() { anyhow::bail!("bazel: no subcommand specified"); @@ -478,16 +840,9 @@ pub fn run_other(args: &[OsString], verbose: u8) -> Result<()> { mod tests { use super::*; - fn default_root() -> (String, String) { - ("//...".to_string(), "//".to_string()) - } - - fn query(stdout: &str, stderr: &str, depth: usize, width: usize) -> String { - filter_bazel_query(stdout, stderr, depth, width, &default_root()) - } - - // === Limit type tests === - + /******************************************************************/ + /* Shared Bazel utilities tests */ + /******************************************************************/ #[test] fn test_limit_from_str() { assert_eq!("1".parse::().unwrap(), Limit::N(1)); @@ -500,6 +855,287 @@ mod tests { assert!("".parse::().is_err()); } + /******************************************************************/ + /* bazel build tests */ + /******************************************************************/ + fn build(stdout: &str, stderr: &str) -> String { + filter_bazel_build(stdout, stderr) + } + + fn count_tokens(text: &str) -> usize { + text.split_whitespace().count() + } + + #[test] + fn test_filter_bazel_build_success() { + let stderr = "\ +Computing main repo mapping: +Loading: +Loading: 1 packages loaded +Analyzing: target //src:bazel-dev (6 packages loaded, 6 targets configured) +INFO: Analyzed target //src:bazel-dev (563 packages loaded, 24852 targets configured, 175 aspect applications). +[1 / 1] no actions running +[889 / 4,978] Compiling absl/numeric/int128.cc; 0s processwrapper-sandbox ... (256 actions, 255 running) +[1,084 / 4,978] Compiling absl/time/internal/cctz/src/time_zone_info.cc; 1s processwrapper-sandbox ... (256 actions, 255 running) +[4,976 / 4,978] Executing genrule //src:package-zip_jdk_allmodules; 1s processwrapper-sandbox +INFO: Found 1 target... +Target //src:bazel-dev up-to-date: + bazel-bin/src/bazel-dev +INFO: Elapsed time: 54.859s, Critical Path: 49.98s +INFO: 2391 processes: 3 internal, 1537 processwrapper-sandbox, 881 worker. +INFO: Build completed successfully, 2391 total actions"; + let result = build("", stderr); + assert_eq!(result, "✓ bazel build (2391 actions)"); + } + + #[test] + fn test_filter_bazel_build_with_warnings() { + let stderr = "\ +Computing main repo mapping: +Loading: +Loading: 1 packages loaded +Analyzing: target //src:bazel-dev (6 packages loaded, 6 targets configured) +WARNING: /home/user/bazel/src/conditions/BUILD:119:15: select() on cpu is deprecated. +WARNING: /home/user/bazel/src/conditions/BUILD:202:15: select() on cpu is deprecated. +WARNING: /home/user/bazel/src/conditions/BUILD:193:15: select() on cpu is deprecated. +INFO: Analyzed target //src:bazel-dev (563 packages loaded). +[889 / 4,978] Compiling absl/numeric/int128.cc; 0s processwrapper-sandbox +[4,976 / 4,978] Executing genrule //src:package-zip_jdk_allmodules; 1s +INFO: Found 1 target... +Target //src:bazel-dev up-to-date: + bazel-bin/src/bazel-dev +INFO: Elapsed time: 54.859s, Critical Path: 49.98s +INFO: Build completed successfully, 4978 total actions"; + let result = build("", stderr); + + assert!(result.contains("bazel build: 0 errors, 3 warnings (4978 actions)")); + assert!(result.contains("═══════════════════════════════════════")); + assert!(result.contains("WARNING:")); + assert!(result.contains("select() on cpu is deprecated")); + // Noise should be stripped + assert!(!result.contains("Loading:")); + assert!(!result.contains("Analyzing:")); + assert!(!result.contains("[889 / 4,978]")); + assert!(!result.contains("INFO:")); + } + + #[test] + fn test_filter_bazel_build_errors() { + let stderr = "\ +Computing main repo mapping: +Loading: +Loading: 0 packages loaded +WARNING: Target pattern parsing failed. +ERROR: Skipping '//src:bazel-dev-NONEXISTENT': no such target '//src:bazel-dev-NONEXISTENT' +ERROR: no such target '//src:bazel-dev-NONEXISTENT': target 'bazel-dev-NONEXISTENT' not declared in package 'src' +INFO: Elapsed time: 0.142s +INFO: 0 processes. +ERROR: Build did NOT complete successfully"; + let result = build("", stderr); + + assert!(result.contains("bazel build: 2 errors, 1 warning")); + assert!(result.contains("(0 actions)")); + assert!(result.contains("ERROR: Skipping")); + assert!(result.contains("ERROR: no such target")); + assert!(result.contains("WARNING: Target pattern parsing failed")); + // "Build did NOT complete successfully" is stripped (we have our own header) + assert!(!result.contains("Build did NOT complete successfully")); + // Noise stripped + assert!(!result.contains("Loading:")); + assert!(!result.contains("INFO:")); + } + + #[test] + fn test_filter_bazel_build_compiler_warnings() { + let stderr = "\ +INFO: Analyzed target //src:bazel-dev (563 packages loaded). +[100 / 200] Compiling something.cc +INFO: From Building external/protobuf+/java/core/liblite_runtime_only.jar (94 source files): +bazel-out/k8-fastbuild/bin/src/main/protobuf/failure_details.pb.h:9953:111: warning: 'some_field' is deprecated [-Wdeprecated-declarations] + 9953 | [[deprecated]] static constexpr Code FIELD = value; + | ^~~~~ +bazel-out/k8-fastbuild/bin/src/main/protobuf/failure_details.pb.h:1690:3: note: declared here + 1690 | SomeField [[deprecated]] = 2, + | ^~~~~~~~~ + +[200 / 200] Linking src/main/cpp/client +INFO: Build completed successfully, 200 total actions"; + let result = build("", stderr); + + // Should keep the compiler warning block + assert!(result.contains("warning:")); + assert!(result.contains("deprecated")); + assert!(result.contains("note: declared here")); + // Should show warning count + assert!(result.contains("1 warning")); + // Noise stripped + assert!(!result.contains("[100 / 200]")); + assert!(!result.contains("[200 / 200]")); + assert!(!result.contains("INFO:")); + } + + #[test] + fn test_filter_bazel_build_strips_progress() { + let stderr = "\ +[1 / 1] no actions running +[889 / 4,978] Compiling absl/numeric/int128.cc; 0s processwrapper-sandbox +[1,084 / 4,978] Compiling absl/time/internal/cctz/src/time_zone_info.cc; 1s +[4,976 / 4,978] Executing genrule //src:package-zip; 1s +INFO: Build completed successfully, 4978 total actions"; + let result = build("", stderr); + + assert!(!result.contains("[889")); + assert!(!result.contains("[1,084")); + assert!(!result.contains("[4,976")); + assert!(!result.contains("[1 / 1]")); + assert!(result.contains("✓ bazel build (4978 actions)")); + } + + #[test] + fn test_filter_bazel_build_strips_info_noise() { + let stderr = "\ +Computing main repo mapping: +Loading: +Loading: 1 packages loaded +Analyzing: target //src:bazel-dev (6 packages loaded) +INFO: Analyzed target //src:bazel-dev +INFO: Found 1 target... +INFO: Elapsed time: 54.859s +INFO: 2391 processes: 3 internal, 1537 processwrapper-sandbox +INFO: Build completed successfully, 100 total actions +Note: Some input files use or override a deprecated API. +Note: Recompile with -Xlint:removal for details. +Target //src:bazel-dev up-to-date: + bazel-bin/src/bazel-dev +DEBUG: some debug info"; + let result = build("", stderr); + + assert!(!result.contains("Computing main repo")); + assert!(!result.contains("Loading:")); + assert!(!result.contains("Analyzing:")); + assert!(!result.contains("INFO:")); + assert!(!result.contains("Note:")); + assert!(!result.contains("Target //src:bazel-dev up-to-date")); + assert!(!result.contains("bazel-bin/")); + assert!(!result.contains("DEBUG:")); + assert!(result.contains("✓ bazel build (100 actions)")); + } + + #[test] + fn test_filter_bazel_build_empty() { + let result = build("", ""); + assert_eq!(result, "✓ bazel build (0 actions)"); + } + + #[test] + fn test_filter_bazel_build_token_savings() { + // Real-ish bazel build output (~80 lines of noise) + let stderr = "\ +Computing main repo mapping: +Loading: +Loading: 1 packages loaded +Analyzing: target //src:bazel-dev (6 packages loaded, 6 targets configured) +Analyzing: target //src:bazel-dev (6 packages loaded, 6 targets configured) +WARNING: /home/user/bazel/src/conditions/BUILD:119:15: select() on cpu is deprecated. +WARNING: /home/user/bazel/src/conditions/BUILD:202:15: select() on cpu is deprecated. +WARNING: /home/user/bazel/src/conditions/BUILD:193:15: select() on cpu is deprecated. +DEBUG: /home/user/.cache/bazel/external/grpc-java/java_grpc_library.bzl:202:14: Multiple values deprecated +INFO: Analyzed target //src:bazel-dev (563 packages loaded, 24852 targets configured). +[1 / 1] no actions running +[889 / 4,978] Compiling absl/numeric/int128.cc; 0s processwrapper-sandbox ... (256 actions, 255 running) +[1,084 / 4,978] Compiling absl/time/internal/cctz/src/time_zone_info.cc; 1s processwrapper-sandbox ... (256 actions, 255 running) +[1,191 / 4,978] Compiling tools/cpp/modules_tools/common/common.cc; 2s processwrapper-sandbox ... (256 actions, 255 running) +[1,348 / 4,978] Executing genrule //src:embedded_jdk_allmodules; 3s processwrapper-sandbox ... (256 actions, 255 running) +[1,469 / 4,978] Executing genrule //src:embedded_jdk_allmodules; 4s processwrapper-sandbox ... (256 actions, 255 running) +[1,540 / 4,978] Executing genrule //src:embedded_jdk_allmodules; 6s processwrapper-sandbox ... (256 actions, 255 running) +[1,605 / 4,978] Executing genrule //src:embedded_jdk_allmodules; 7s processwrapper-sandbox ... (255 actions, 254 running) +[1,642 / 4,978] Executing genrule //src:embedded_jdk_allmodules; 8s processwrapper-sandbox ... (240 actions running) +[1,681 / 4,978] Executing genrule //src:embedded_jdk_allmodules; 9s processwrapper-sandbox ... (201 actions running) +[1,751 / 4,978] Executing genrule //src:embedded_jdk_allmodules; 10s processwrapper-sandbox ... (256 actions, 202 running) +[1,810 / 4,978] Executing genrule //src:embedded_jdk_allmodules; 12s processwrapper-sandbox ... (224 actions, 155 running) +[1,846 / 4,978] Executing genrule //src:embedded_jdk_allmodules; 13s processwrapper-sandbox ... (188 actions, 128 running) +[1,904 / 4,978] Executing genrule //src:embedded_jdk_allmodules; 14s processwrapper-sandbox ... (130 actions, 92 running) +[1,970 / 4,978] Executing genrule //src:embedded_jdk_allmodules; 15s processwrapper-sandbox ... (179 actions, 151 running) +INFO: From Building external/zstd-jni+/libzstd-jni-class.jar (30 source files) [for tool]: +Note: Some input files use or override a deprecated API that is marked for removal. +Note: Recompile with -Xlint:removal for details. +[2,149 / 4,978] Executing genrule //src:embedded_jdk_allmodules; 16s processwrapper-sandbox ... (85 actions, 54 running) +INFO: From Building external/zstd-jni+/libzstd-jni-class.jar (30 source files): +Note: Some input files use or override a deprecated API that is marked for removal. +Note: Recompile with -Xlint:removal for details. +[2,318 / 4,978] Executing genrule //src:embedded_jdk_allmodules; 17s processwrapper-sandbox +[2,346 / 4,978] Executing genrule //src:embedded_jdk_allmodules; 18s processwrapper-sandbox +[2,368 / 4,978] Executing genrule //src:embedded_jdk_allmodules; 19s processwrapper-sandbox +[4,974 / 4,978] Linking src/main/cpp/client; 1s processwrapper-sandbox +[4,976 / 4,978] Executing genrule //src:package-zip_jdk_allmodules; 1s processwrapper-sandbox +INFO: Found 1 target... +Target //src:bazel-dev up-to-date: + bazel-bin/src/bazel-dev +INFO: Elapsed time: 54.859s, Critical Path: 49.98s +INFO: 2391 processes: 3 internal, 1537 processwrapper-sandbox, 881 worker. +INFO: Build completed successfully, 2391 total actions"; + + let input_tokens = count_tokens(stderr); + let result = build("", stderr); + let output_tokens = count_tokens(&result); + + let savings = 100.0 - (output_tokens as f64 / input_tokens as f64 * 100.0); + assert!( + savings >= 60.0, + "Bazel build filter: expected ≥60% savings, got {:.1}% ({} → {} tokens)", + savings, + input_tokens, + output_tokens + ); + } + + #[test] + fn test_filter_bazel_build_truncates_blocks() { + // More than 15 issues should truncate + let mut stderr = String::new(); + for i in 0..20 { + stderr.push_str(&format!("ERROR: //pkg:target_{}: build failed\n\n", i)); + } + let result = build("", &stderr); + + assert!(result.contains("... +5 more issues")); + } + + #[test] + fn test_filter_bazel_build_mixed_compiler_and_bazel_errors() { + let stderr = "\ +WARNING: /home/user/bazel/BUILD:10:5: select() on cpu is deprecated. +INFO: Analyzed target //src:app +[10 / 100] Compiling src/app.cc +src/app.cc:42:10: error: use of undeclared identifier 'foo' + 42 | foo(); + | ^~~ + +ERROR: //src:app failed to build +INFO: Build completed, 0 total actions +ERROR: Build did NOT complete successfully"; + let result = build("", stderr); + + // Should have 2 errors (compiler + bazel ERROR) and 1 warning + assert!(result.contains("2 errors")); + assert!(result.contains("1 warning")); + assert!(result.contains("error: use of undeclared identifier")); + assert!(result.contains("ERROR: //src:app failed to build")); + assert!(result.contains("WARNING:")); + assert!(result.contains("select() on cpu is deprecated")); + } + + /******************************************************************/ + /* bazel query tests */ + /******************************************************************/ + fn default_root() -> (String, String) { + ("//...".to_string(), "//".to_string()) + } + + fn query(stdout: &str, stderr: &str, depth: usize, width: usize) -> String { + filter_bazel_query(stdout, stderr, depth, width, &default_root()) + } + #[test] fn test_limit_value() { assert_eq!(Limit::N(5).value(), 5); @@ -512,8 +1148,6 @@ mod tests { assert_eq!(Limit::All.to_string(), "all"); } - // === Helper function tests === - #[test] fn test_detect_query_root() { // Single //... @@ -562,8 +1196,6 @@ mod tests { ); } - // === Core filter tests === - #[test] fn test_strips_info_warning_noise() { let stderr = "\ @@ -628,8 +1260,6 @@ some non-target output line assert!(result.contains("(1 target)")); } - // === Header line tests === - #[test] fn test_header_line() { let stdout = "\ @@ -642,8 +1272,6 @@ some non-target output line assert!(result.starts_with("//... (3 targets, 3 packages)")); } - // === Depth tests === - #[test] fn test_depth_1_collapses_to_summary() { let stdout = "\ @@ -727,8 +1355,6 @@ some non-target output line assert!(result.contains(" 📦 java (1 target, 1 package)")); } - // === Width tests === - #[test] fn test_width_budget_packages_then_targets() { // Width 5: 3 sub-packages take 3 slots, 2 remaining for targets @@ -808,8 +1434,6 @@ some non-target output line assert!(!result.contains("more target")); } - // === Root target tests === - #[test] fn test_root_targets_inline() { let stdout = "\ @@ -825,8 +1449,6 @@ some non-target output line assert!(result.contains("📦 src")); } - // === Relative names tests === - #[test] fn test_relative_names() { let stdout = "\ @@ -841,10 +1463,6 @@ some non-target output line assert!(!result.contains("examples/go")); } - // === Multi-root tests === - - // === Backward-compatible tests (ported from old tests) === - #[test] fn test_groups_targets_by_package() { let stdout = "\ diff --git a/src/main.rs b/src/main.rs index 443feffb..e8510d58 100644 --- a/src/main.rs +++ b/src/main.rs @@ -912,6 +912,12 @@ enum CargoCommands { #[derive(Subcommand)] enum BazelCommands { + /// Build with compact output (errors/warnings only, 85% token reduction) + Build { + /// Additional bazel build arguments + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, /// Query with grouped target output (85% token reduction) Query { /// Maximum depth of package tree to show (default: 1, "all" for unlimited) @@ -1546,6 +1552,9 @@ fn main() -> Result<()> { BazelCommands::Query { depth, width, args } => { bazel_cmd::run_query(&args, depth, width, cli.verbose)?; } + BazelCommands::Build { args } => { + bazel_cmd::run_build(&args, cli.verbose)?; + } BazelCommands::Other(args) => { bazel_cmd::run_other(&args, cli.verbose)?; } From 80f724c2381ca12de59b184580b18635c705aad4 Mon Sep 17 00:00:00 2001 From: cmolder <28611108+cmolder@users.noreply.github.com> Date: Mon, 2 Mar 2026 15:52:38 -0800 Subject: [PATCH 3/9] feat(bazel): add `rtk bazel test` with failures-only output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strip build noise (progress, INFO, Loading, Analyzing) and test metadata, showing only a one-liner on success or FAIL blocks with inline test output on failure. 9 tests covering all-pass, cached, failure with inline output, build errors, and ≥60% token savings. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/bazel_cmd.rs | 574 +++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 9 + 2 files changed, 583 insertions(+) diff --git a/src/bazel_cmd.rs b/src/bazel_cmd.rs index 55e50de5..f847e069 100644 --- a/src/bazel_cmd.rs +++ b/src/bazel_cmd.rs @@ -66,6 +66,42 @@ lazy_static! { /// e.g. "(10:23:45) WARNING: Warning message..." static ref WARNING_WITH_TIMESTAMP: Regex = Regex::new(r"^\(\d+:\d+:\d+\)\s*WARNING:").unwrap(); + + /// Matches test result lines + /// + /// e.g. "//pkg:test PASSED in 0.3s", "//pkg:test (cached) PASSED in 0.3s" + static ref TEST_RESULT_LINE: Regex = + Regex::new(r"^(//\S+)\s+(?:\(cached\)\s+)?(PASSED|FAILED|TIMEOUT|FLAKY|NO STATUS)\s+in\s+([\d.]+)s").unwrap(); + + /// Matches the Executed summary line + /// + /// e.g. "Executed 3 out of 3 tests: 3 tests pass." + static ref TEST_SUMMARY: Regex = + Regex::new(r"^Executed\s+(\d+)\s+out\s+of\s+(\d+)\s+tests?:").unwrap(); + + /// Matches test output delimiters + /// + /// e.g. "==================== Test output for //pkg:test:" + static ref TEST_OUTPUT_START: Regex = + Regex::new(r"^={10,}\s+Test output for\s+").unwrap(); + + /// Matches test output end delimiter + /// + /// e.g. "================================================================================" + static ref TEST_OUTPUT_END: Regex = + Regex::new(r"^={40,}$").unwrap(); + + /// Matches FAIL: lines with target + /// + /// e.g. "FAIL: //pkg:test (Exit 1) (see /path/to/test.log)" + static ref FAIL_LINE: Regex = + Regex::new(r"^FAIL:\s+(//\S+)").unwrap(); + + /// Matches elapsed time from INFO lines + /// + /// e.g. "INFO: Elapsed time: 3.89s, Critical Path: 1.23s" + static ref ELAPSED_TIME: Regex = + Regex::new(r"Elapsed time:\s*([\d.]+)s").unwrap(); } /// A limit value that can be a specific number or unlimited ("all"). @@ -384,6 +420,317 @@ pub fn run_build(args: &[String], verbose: u8) -> Result<()> { Ok(()) } +/**********************************************************************/ +/* bazel test */ +/**********************************************************************/ + +/// Filter `bazel test` output. +/// +/// # Arguments +/// +/// * `stdout` - stdout output from `bazel test` +/// * `stderr` - stderr output from `bazel test` +/// +/// # Returns +/// +/// The filtered `bazel test` output +/// +/// # Notes +/// +/// Strips the same build noise as `filter_bazel_build`, plus parses test +/// result lines (PASSED/FAILED/TIMEOUT) and inline test output blocks. +/// On all-pass, returns a one-liner. On failure, shows FAIL blocks and +/// inline test output while stripping surrounding noise. +/// +pub fn filter_bazel_test(stdout: &str, stderr: &str) -> String { + let mut passed: usize = 0; + let mut failed: usize = 0; + let mut elapsed: Option = None; + let mut error_lines: Vec = Vec::new(); + let mut fail_blocks: Vec = Vec::new(); + let mut failed_result_lines: Vec = Vec::new(); + let mut inline_output_blocks: Vec = Vec::new(); + + // State for collecting inline test output + let mut in_test_output = false; + let mut current_output_block: Vec = Vec::new(); + + // Combine stderr + stdout (bazel sends most to stderr) + let combined = format!("{}\n{}", stderr, stdout); + + for line in combined.lines() { + let trimmed = line.trim(); + + // Collecting inline test output between delimiter lines + if in_test_output { + if TEST_OUTPUT_END.is_match(trimmed) { + current_output_block.push(trimmed.to_string()); + inline_output_blocks.push(current_output_block.join("\n")); + current_output_block.clear(); + in_test_output = false; + } else { + current_output_block.push(line.to_string()); + } + continue; + } + + if trimmed.is_empty() { + continue; + } + + // Extract elapsed time before skipping INFO lines + if trimmed.starts_with("INFO:") || NOISE_WITH_TIMESTAMP.is_match(trimmed) { + if let Some(caps) = ELAPSED_TIME.captures(trimmed) { + elapsed = Some(caps[1].to_string()); + } + continue; + } + + // Strip progress lines: [N / M] ... + if PROGRESS_LINE.is_match(trimmed) { + continue; + } + + // Strip loading/analyzing status + if trimmed.starts_with("Loading:") + || trimmed.starts_with("Analyzing:") + || trimmed.starts_with("Computing main repo mapping:") + { + continue; + } + + // Strip Java notes + if trimmed.starts_with("Note: ") { + continue; + } + + // Strip target output paths + if trimmed.starts_with("Target //") || trimmed.starts_with("bazel-bin/") { + continue; + } + + // Strip DEBUG lines + if trimmed.starts_with("DEBUG:") { + continue; + } + + // Strip timeout size warnings + if trimmed.starts_with("There were tests whose specified size") { + continue; + } + + // Test result lines: //pkg:test PASSED in 0.3s + if let Some(caps) = TEST_RESULT_LINE.captures(trimmed) { + let status = &caps[2]; + match status { + "PASSED" => passed += 1, + "FAILED" | "TIMEOUT" | "NO STATUS" => { + failed += 1; + failed_result_lines.push(trimmed.to_string()); + } + "FLAKY" => passed += 1, // flaky but passed on retry + _ => {} + } + continue; + } + + // Executed summary line (skip — we produce our own) + if TEST_SUMMARY.is_match(trimmed) { + continue; + } + + // FAIL: lines + if FAIL_LINE.is_match(trimmed) { + fail_blocks.push(trimmed.to_string()); + continue; + } + + // Inline test output start + if TEST_OUTPUT_START.is_match(trimmed) { + in_test_output = true; + current_output_block.push(trimmed.to_string()); + continue; + } + + // ERROR lines + if ERROR_PLAIN.is_match(trimmed) || ERROR_WITH_TIMESTAMP.is_match(trimmed) { + if trimmed.contains("Build did NOT complete successfully") + || trimmed.contains("not all tests passed") + { + continue; + } + error_lines.push(trimmed.to_string()); + continue; + } + + // WARNING lines (strip — build noise) + if WARNING_PLAIN.is_match(trimmed) || WARNING_WITH_TIMESTAMP.is_match(trimmed) { + continue; + } + + // Indented log paths after FAILED lines (e.g. " /path/to/test.log") + // Keep only if we have failures + if trimmed.starts_with('/') && trimmed.ends_with(".log") && failed > 0 { + continue; // skip log paths — we show inline output instead + } + + // Everything else is noise — skip + } + + // Flush any unclosed test output block + if !current_output_block.is_empty() { + inline_output_blocks.push(current_output_block.join("\n")); + } + + let elapsed_str = elapsed.unwrap_or_else(|| "0".to_string()); + + // Build error — no test results but ERROR lines present + if passed == 0 && failed == 0 && !error_lines.is_empty() { + let mut result = String::from("bazel test: build failed\n"); + result.push_str("═══════════════════════════════════════\n"); + for err in error_lines.iter().take(15) { + result.push_str(err); + result.push('\n'); + } + if error_lines.len() > 15 { + result.push_str(&format!("\n... +{} more errors\n", error_lines.len() - 15)); + } + return result.trim().to_string(); + } + + // All pass: one-liner + if failed == 0 { + return format!( + "\u{2713} bazel test: {} passed, 0 failed ({}s)", + passed, elapsed_str + ); + } + + // Has failures: show details + let mut result = String::new(); + result.push_str(&format!( + "bazel test: {} failed, {} passed ({}s)\n", + failed, passed, elapsed_str + )); + result.push_str("═══════════════════════════════════════\n"); + + // FAIL: lines + let mut block_count = 0; + for fail in &fail_blocks { + if block_count >= 15 { + break; + } + result.push_str(fail); + result.push('\n'); + block_count += 1; + } + + // Inline test output blocks + for block in &inline_output_blocks { + if block_count >= 15 { + break; + } + if !result.ends_with('\n') { + result.push('\n'); + } + result.push_str(block); + result.push('\n'); + block_count += 1; + } + + // FAILED result lines + for line in &failed_result_lines { + if block_count >= 15 { + break; + } + if !result.ends_with('\n') { + result.push('\n'); + } + result.push_str(line); + result.push('\n'); + block_count += 1; + } + + // Error lines (if any) + for err in &error_lines { + if block_count >= 15 { + break; + } + result.push_str(err); + result.push('\n'); + block_count += 1; + } + + let total_blocks = fail_blocks.len() + + inline_output_blocks.len() + + failed_result_lines.len() + + error_lines.len(); + if total_blocks > 15 { + result.push_str(&format!("\n... +{} more blocks\n", total_blocks - 15)); + } + + result.trim().to_string() +} + +/// Run `bazel test` while filtering the output. +/// +/// # Arguments +/// +/// * `args` - Arguments to pass to `bazel test` +/// * `verbose` - Verbosity level +/// +/// # Returns +/// +/// Result of the operation +/// +pub fn run_test(args: &[String], verbose: u8) -> Result<()> { + let timer = tracking::TimedExecution::start(); + + let mut cmd = Command::new("bazel"); + cmd.arg("test"); + + for arg in args { + cmd.arg(arg); + } + + if verbose > 0 { + eprintln!("Running: bazel test {}", args.join(" ")); + } + + let output = cmd + .output() + .context("Failed to run bazel test. Is Bazel 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_bazel_test(&stdout, &stderr); + + if let Some(hint) = crate::tee::tee_and_hint(&raw, "bazel_test", exit_code) { + println!("{}\n{}", filtered, hint); + } else { + println!("{}", filtered); + } + + timer.track( + &format!("bazel test {}", args.join(" ")), + &format!("rtk bazel test {}", args.join(" ")), + &raw, + &filtered, + ); + + if !output.status.success() { + std::process::exit(exit_code); + } + + Ok(()) +} + /**********************************************************************/ /* bazel query */ /**********************************************************************/ @@ -1521,4 +1868,231 @@ some non-target output line assert!(result.contains("🎯 :bar\n")); assert!(result.contains("🎯 :runner_test")); } + + /******************************************************************/ + /* bazel test tests */ + /******************************************************************/ + fn btest(stdout: &str, stderr: &str) -> String { + filter_bazel_test(stdout, stderr) + } + + #[test] + fn test_filter_bazel_test_all_pass() { + let stderr = "\ +Computing main repo mapping: +Loading: +Loading: 0 packages loaded +Analyzing: 3 targets (81 packages loaded, 684 targets configured) +INFO: Analyzed 3 targets (81 packages loaded, 684 targets configured). +INFO: Found 3 test targets... +[0 / 4] [Prepa] BazelWorkspaceStatusAction stable-status.txt +[5 / 14] Compiling src/test/java/com/google/devtools/build/lib/util/CommandUtilsTest.java; 0s worker +[14 / 14] 3 tests, 1 action running +//src/test/java/com/google/devtools/build/lib/util:CommandUtilsTest PASSED in 0.3s +//src/test/java/com/google/devtools/build/lib/util:DecimalBucketerTest PASSED in 0.3s +//src/test/java/com/google/devtools/build/lib/util:StringEncodingTest PASSED in 0.3s +INFO: Elapsed time: 5.164s, Critical Path: 3.89s +INFO: 6 processes: 3 internal, 3 worker. +INFO: Build completed successfully, 6 total actions +Executed 3 out of 3 tests: 3 tests pass."; + let result = btest("", stderr); + assert_eq!(result, "\u{2713} bazel test: 3 passed, 0 failed (5.164s)"); + } + + #[test] + fn test_filter_bazel_test_with_cached() { + let stderr = "\ +Loading: +INFO: Analyzed 2 targets (0 packages loaded, 0 targets configured). +INFO: Found 2 test targets... +//src/test/java/com/google/devtools/build/lib/util:CommandUtilsTest (cached) PASSED in 0.3s +//src/test/java/com/google/devtools/build/lib/util:StringEncodingTest PASSED in 0.1s +INFO: Elapsed time: 0.412s, Critical Path: 0.10s +INFO: 2 processes: 1 internal, 1 worker. +INFO: Build completed successfully, 2 total actions +Executed 1 out of 2 tests: 2 tests pass."; + let result = btest("", stderr); + assert_eq!(result, "\u{2713} bazel test: 2 passed, 0 failed (0.412s)"); + } + + #[test] + fn test_filter_bazel_test_failure() { + let stderr = "\ +Loading: +INFO: Analyzed 1 target (0 packages loaded, 0 targets configured). +INFO: Found 1 test target... +FAIL: //src/test/java/com/google/devtools/build/lib/util:StringEncodingTest (Exit 1) (see /home/user/.cache/bazel/_bazel_user/abc/execroot/io_bazel/bazel-out/k8-fastbuild/testlogs/src/test/java/com/google/devtools/build/lib/util/StringEncodingTest/test.log) +//src/test/java/com/google/devtools/build/lib/util:StringEncodingTest FAILED in 0.3s + /home/user/.cache/bazel/testlogs/src/test/java/com/google/devtools/build/lib/util/StringEncodingTest/test.log +INFO: Elapsed time: 0.340s, Critical Path: 0.30s +INFO: 2 processes: 1 internal, 1 worker. +INFO: Build completed, 1 test FAILED, 2 total actions +Executed 1 out of 1 test: 1 fails locally."; + let result = btest("", stderr); + + assert!(result.contains("bazel test: 1 failed, 0 passed (0.340s)")); + assert!(result.contains("═══════════════════════════════════════")); + assert!(result.contains("FAIL: //src/test/java")); + assert!(result.contains("FAILED in 0.3s")); + // Noise stripped + assert!(!result.contains("Loading:")); + assert!(!result.contains("INFO:")); + assert!(!result.contains("Executed 1 out of")); + } + + #[test] + fn test_filter_bazel_test_failure_with_test_output() { + let stderr = "\ +Loading: +INFO: Analyzed 1 target (0 packages loaded, 0 targets configured). +INFO: Found 1 test target... +FAIL: //src/test/java/com/google/devtools/build/lib/util:StringEncodingTest (Exit 1) +==================== Test output for //src/test/java/com/google/devtools/build/lib/util:StringEncodingTest: +JUnit4 Test Runner +.EE +Time: 0.002 +There were 2 failures: +1) initializationError(org.junit.runner.manipulation.Filter) +java.lang.Exception: No tests found matching RegEx[NONEXISTENT_TEST] +\tat org.junit.internal.requests.FilterRequest.getRunner(FilterRequest.java:40) +\tat com.google.testing.junit.runner.internal.junit4.JUnit4Runner.createErrorReportingRequestForFilterError(JUnit4Runner.java:233) +================================================================================ +//src/test/java/com/google/devtools/build/lib/util:StringEncodingTest FAILED in 0.3s +INFO: Elapsed time: 0.340s, Critical Path: 0.30s +INFO: Build completed, 1 test FAILED, 2 total actions +Executed 1 out of 1 test: 1 fails locally."; + let result = btest("", stderr); + + assert!(result.contains("bazel test: 1 failed, 0 passed")); + // Inline test output preserved + assert!(result.contains("==================== Test output for")); + assert!(result.contains("JUnit4 Test Runner")); + assert!(result.contains("No tests found matching")); + assert!(result.contains( + "================================================================================" + )); + // FAIL line preserved + assert!(result.contains("FAIL: //src/test/java")); + // Result line preserved + assert!(result.contains("FAILED in 0.3s")); + } + + #[test] + fn test_filter_bazel_test_strips_build_noise() { + let stderr = "\ +Computing main repo mapping: +Loading: +Loading: 0 packages loaded +Analyzing: 1 target (81 packages loaded, 684 targets configured) +INFO: Analyzed 1 target (81 packages loaded). +[0 / 4] [Prepa] BazelWorkspaceStatusAction stable-status.txt +[5 / 14] Compiling something.java; 0s worker +[14 / 14] 1 test running +INFO: Found 1 test target... +DEBUG: /some/debug/info +Note: Some input files use deprecated API. +Target //src:target up-to-date: + bazel-bin/src/target +//pkg:test PASSED in 0.5s +INFO: Elapsed time: 1.00s, Critical Path: 0.50s +INFO: Build completed successfully, 4 total actions +Executed 1 out of 1 test: 1 tests pass."; + let result = btest("", stderr); + + assert!(!result.contains("Computing main repo")); + assert!(!result.contains("Loading:")); + assert!(!result.contains("Analyzing:")); + assert!(!result.contains("[0 / 4]")); + assert!(!result.contains("[5 / 14]")); + assert!(!result.contains("[14 / 14]")); + assert!(!result.contains("INFO:")); + assert!(!result.contains("DEBUG:")); + assert!(!result.contains("Note:")); + assert!(!result.contains("Target //src:target")); + assert!(!result.contains("bazel-bin/")); + assert!(!result.contains("Executed 1 out of")); + assert!(result.contains("\u{2713} bazel test: 1 passed, 0 failed")); + } + + #[test] + fn test_filter_bazel_test_build_error() { + let stderr = "\ +Loading: +WARNING: Target pattern parsing failed. +ERROR: Skipping '//src:nonexistent': no such target '//src:nonexistent' +ERROR: no such target '//src:nonexistent': target 'nonexistent' not declared +INFO: Elapsed time: 0.142s +INFO: 0 processes. +ERROR: Build did NOT complete successfully"; + let result = btest("", stderr); + + assert!(result.contains("bazel test: build failed")); + assert!(result.contains("═══════════════════════════════════════")); + assert!(result.contains("ERROR: Skipping")); + assert!(result.contains("ERROR: no such target")); + // "Build did NOT complete successfully" stripped + assert!(!result.contains("Build did NOT complete successfully")); + } + + #[test] + fn test_filter_bazel_test_empty() { + let result = btest("", ""); + assert_eq!(result, "\u{2713} bazel test: 0 passed, 0 failed (0s)"); + } + + #[test] + fn test_filter_bazel_test_token_savings() { + let stderr = "\ +Computing main repo mapping: +Loading: +Loading: 0 packages loaded +Analyzing: 3 targets (81 packages loaded, 684 targets configured) +Analyzing: 3 targets (81 packages loaded, 684 targets configured) +INFO: Analyzed 3 targets (81 packages loaded, 684 targets configured). +INFO: Found 3 test targets... +[0 / 4] [Prepa] BazelWorkspaceStatusAction stable-status.txt +[1 / 14] Compiling src/test/java/com/google/devtools/build/lib/util/CommandUtilsTest.java; 0s worker +[2 / 14] Compiling src/test/java/com/google/devtools/build/lib/util/DecimalBucketerTest.java; 0s worker +[5 / 14] Compiling src/test/java/com/google/devtools/build/lib/util/StringEncodingTest.java; 0s worker +[10 / 14] Building test deploy jar +[14 / 14] 3 tests, 1 action running +//src/test/java/com/google/devtools/build/lib/util:CommandUtilsTest PASSED in 0.3s +//src/test/java/com/google/devtools/build/lib/util:DecimalBucketerTest PASSED in 0.3s +//src/test/java/com/google/devtools/build/lib/util:StringEncodingTest PASSED in 0.3s +There were tests whose specified size is too big. Use the --test_verbose_timeout_warnings command line option to see which ones these are. +INFO: Elapsed time: 5.164s, Critical Path: 3.89s +INFO: 6 processes: 3 internal, 3 worker. +INFO: Build completed successfully, 6 total actions +Executed 3 out of 3 tests: 3 tests pass."; + + let input_tokens = count_tokens(stderr); + let result = btest("", stderr); + let output_tokens = count_tokens(&result); + + let savings = 100.0 - (output_tokens as f64 / input_tokens as f64 * 100.0); + assert!( + savings >= 60.0, + "Bazel test filter: expected ≥60% savings, got {:.1}% ({} → {} tokens)", + savings, + input_tokens, + output_tokens + ); + } + + #[test] + fn test_filter_bazel_test_strips_timeout_warnings() { + let stderr = "\ +INFO: Analyzed 1 target (0 packages loaded). +INFO: Found 1 test target... +//pkg:test PASSED in 0.5s +There were tests whose specified size is too big. Use the --test_verbose_timeout_warnings command line option to see which ones these are. +INFO: Elapsed time: 1.00s, Critical Path: 0.50s +INFO: Build completed successfully, 2 total actions +Executed 1 out of 1 test: 1 tests pass."; + let result = btest("", stderr); + + assert!(!result.contains("There were tests whose specified size")); + assert!(!result.contains("--test_verbose_timeout_warnings")); + assert!(result.contains("\u{2713} bazel test: 1 passed, 0 failed")); + } } diff --git a/src/main.rs b/src/main.rs index e8510d58..5c6e78f2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -918,6 +918,12 @@ enum BazelCommands { #[arg(trailing_var_arg = true, allow_hyphen_values = true)] args: Vec, }, + /// Test with compact output (failures only, 85% token reduction) + Test { + /// Additional bazel test arguments + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, /// Query with grouped target output (85% token reduction) Query { /// Maximum depth of package tree to show (default: 1, "all" for unlimited) @@ -1555,6 +1561,9 @@ fn main() -> Result<()> { BazelCommands::Build { args } => { bazel_cmd::run_build(&args, cli.verbose)?; } + BazelCommands::Test { args } => { + bazel_cmd::run_test(&args, cli.verbose)?; + } BazelCommands::Other(args) => { bazel_cmd::run_other(&args, cli.verbose)?; } From ba2f5d73ce8b75e0bae317ebd13c45c5e985bf7e Mon Sep 17 00:00:00 2001 From: cmolder <28611108+cmolder@users.noreply.github.com> Date: Mon, 2 Mar 2026 17:59:37 -0800 Subject: [PATCH 4/9] feat(bazel): add `rtk bazel run` with build-noise filtering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add filter_bazel_run() that splits stderr at the "INFO: Running command line:" sentinel, strips build noise from the build phase, and forwards binary stdout/stderr verbatim. Clean builds show only binary output; build errors show a separate build section with errors and warnings for context. Also refactors shared bazel filtering: - Add strip_timestamp() helper to normalize "(HH:MM:SS) " prefixes, replacing 6 redundant _PLAIN/_WITH_TIMESTAMP regex pairs - Simplify RUN_SENTINEL regex (timestamp handled by helper) - Fix ACTION_COUNT regex to match singular "1 total action" - Apply strip_timestamp to filter_bazel_build and filter_bazel_test 13 tests covering: clean build, warnings stripped, errors with/without warnings, binary stderr, no sentinel fallback, timestamped lines, post-sentinel INFO stripped, real-world output, and ≥60% token savings. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/bazel_cmd.rs | 631 ++++++++++++++++++++++++++++++++++++++++------- src/main.rs | 9 + 2 files changed, 556 insertions(+), 84 deletions(-) diff --git a/src/bazel_cmd.rs b/src/bazel_cmd.rs index f847e069..f5494e61 100644 --- a/src/bazel_cmd.rs +++ b/src/bazel_cmd.rs @@ -13,29 +13,9 @@ use std::str::FromStr; /**********************************************************************/ lazy_static! { - /// Matches Bazel INFO, WARNING, and DEBUG lines - /// - /// e.g. "INFO: Build option..." - static ref NOISE_PLAIN: Regex = - Regex::new(r"^(INFO|WARNING|DEBUG):").unwrap(); - - /// Matches Bazel INFO, WARNING, and DEBUG lines - /// - /// e.g. "(10:23:45) INFO: Build option..." - static ref NOISE_WITH_TIMESTAMP: Regex = - Regex::new(r"^\(\d+:\d+:\d+\)\s*(INFO|WARNING|DEBUG):").unwrap(); - - /// Matches Bazel ERROR lines without timestamp - /// - /// e.g. "ERROR: Compilation failed..." - static ref ERROR_PLAIN: Regex = - Regex::new(r"^ERROR:").unwrap(); - - /// Matches Bazel ERROR lines with timestamp - /// - /// e.g. "(10:23:45) ERROR: Compilation failed..." - static ref ERROR_WITH_TIMESTAMP: Regex = - Regex::new(r"^\(\d+:\d+:\d+\)\s*ERROR:").unwrap(); + /// Matches optional leading Bazel timestamp prefix: "(HH:MM:SS) " + static ref TIMESTAMP_PREFIX: Regex = + Regex::new(r"^\(\d+:\d+:\d+\)\s*").unwrap(); /// Matches Bazel target lines /// @@ -51,21 +31,9 @@ lazy_static! { /// Matches INFO lines with action counts /// - /// e.g. "123 total actions" + /// e.g. "123 total actions", "1 total action" static ref ACTION_COUNT: Regex = - Regex::new(r"(\d[\d,]*)\s+total actions").unwrap(); - - /// Matches WARNING lines without timestamp - /// - /// e.g. "WARNING: Warning message..." - static ref WARNING_PLAIN: Regex = - Regex::new(r"^WARNING:").unwrap(); - - /// Matches WARNING lines with timestamp - /// - /// e.g. "(10:23:45) WARNING: Warning message..." - static ref WARNING_WITH_TIMESTAMP: Regex = - Regex::new(r"^\(\d+:\d+:\d+\)\s*WARNING:").unwrap(); + Regex::new(r"(\d[\d,]*)\s+total actions?").unwrap(); /// Matches test result lines /// @@ -102,6 +70,24 @@ lazy_static! { /// e.g. "INFO: Elapsed time: 3.89s, Critical Path: 1.23s" static ref ELAPSED_TIME: Regex = Regex::new(r"Elapsed time:\s*([\d.]+)s").unwrap(); + + /// Matches the "Running command line:" sentinel that separates build from execution + /// + /// e.g. "INFO: Running command line: bazel-bin/path/to/binary" + /// Note: timestamp prefix is already stripped by strip_timestamp() before matching + static ref RUN_SENTINEL: Regex = + Regex::new(r"^INFO: Running command line:").unwrap(); +} + +/// Strip optional leading Bazel timestamp prefix "(HH:MM:SS) " from a line. +/// +/// Bazel may prepend timestamps to all output lines (e.g. `(17:17:06) Loading:`). +/// This normalizes them so `starts_with` checks work regardless of timestamp presence. +fn strip_timestamp(line: &str) -> &str { + TIMESTAMP_PREFIX + .find(line) + .map(|m| &line[m.end()..]) + .unwrap_or(line) } /// A limit value that can be a specific number or unlimited ("all"). @@ -191,7 +177,9 @@ pub fn filter_bazel_build(stdout: &str, stderr: &str) -> String { for line in combined.lines() { let trimmed = line.trim(); - if trimmed.is_empty() { + // Strip optional "(HH:MM:SS) " timestamp prefix so starts_with checks work + let stripped = strip_timestamp(trimmed); + if stripped.is_empty() { // Blank line ends a diagnostic block if in_diagnostic && !current_block.is_empty() { if current_is_error { @@ -206,8 +194,8 @@ pub fn filter_bazel_build(stdout: &str, stderr: &str) -> String { } // Extract action count from INFO lines before skipping them - if trimmed.starts_with("INFO:") || NOISE_WITH_TIMESTAMP.is_match(trimmed) { - if let Some(caps) = ACTION_COUNT.captures(trimmed) { + if stripped.starts_with("INFO:") || stripped.starts_with("DEBUG:") { + if let Some(caps) = ACTION_COUNT.captures(stripped) { action_count = Some(caps[1].to_string()); } // "INFO: From ..." lines precede compiler output — skip the INFO line itself @@ -216,35 +204,35 @@ pub fn filter_bazel_build(stdout: &str, stderr: &str) -> String { } // Strip progress lines: [N / M] ... - if PROGRESS_LINE.is_match(trimmed) { + if PROGRESS_LINE.is_match(stripped) { continue; } // Strip loading/analyzing status - if trimmed.starts_with("Loading:") - || trimmed.starts_with("Analyzing:") - || trimmed.starts_with("Computing main repo mapping:") + if stripped.starts_with("Loading:") + || stripped.starts_with("Analyzing:") + || stripped.starts_with("Computing main repo mapping:") { continue; } // Strip Java notes - if trimmed.starts_with("Note: ") { + if stripped.starts_with("Note: ") { continue; } // Strip target output paths - if trimmed.starts_with("Target //") || trimmed.starts_with("bazel-bin/") { + if stripped.starts_with("Target //") || stripped.starts_with("bazel-bin/") { continue; } // Strip DEBUG lines - if trimmed.starts_with("DEBUG:") { + if stripped.starts_with("DEBUG:") { continue; } // Bazel-level ERROR lines - if ERROR_PLAIN.is_match(trimmed) || ERROR_WITH_TIMESTAMP.is_match(trimmed) { + if stripped.starts_with("ERROR:") { // Flush any in-progress diagnostic block if in_diagnostic && !current_block.is_empty() { if current_is_error { @@ -256,17 +244,18 @@ pub fn filter_bazel_build(stdout: &str, stderr: &str) -> String { in_diagnostic = false; } // Skip the summary "Build did NOT complete successfully" — we show our own header - if trimmed.contains("Build did NOT complete successfully") { + if stripped.contains("Build did NOT complete successfully") { error_count = error_count.max(1); // ensure we show error header continue; } error_count += 1; - errors.push(trimmed.to_string()); + errors.push(stripped.to_string()); continue; } - // Bazel-level WARNING lines - if WARNING_PLAIN.is_match(trimmed) || WARNING_WITH_TIMESTAMP.is_match(trimmed) { + // Bazel-level WARNING lines (already caught above in INFO/WARNING/DEBUG gate, + // but standalone WARNING lines without prior INFO context reach here) + if stripped.starts_with("WARNING:") { // Flush any in-progress diagnostic block if in_diagnostic && !current_block.is_empty() { if current_is_error { @@ -278,7 +267,7 @@ pub fn filter_bazel_build(stdout: &str, stderr: &str) -> String { in_diagnostic = false; } warning_count += 1; - warnings.push(trimmed.to_string()); + warnings.push(stripped.to_string()); continue; } @@ -460,11 +449,12 @@ pub fn filter_bazel_test(stdout: &str, stderr: &str) -> String { for line in combined.lines() { let trimmed = line.trim(); + let stripped = strip_timestamp(trimmed); // Collecting inline test output between delimiter lines if in_test_output { - if TEST_OUTPUT_END.is_match(trimmed) { - current_output_block.push(trimmed.to_string()); + if TEST_OUTPUT_END.is_match(stripped) { + current_output_block.push(stripped.to_string()); inline_output_blocks.push(current_output_block.join("\n")); current_output_block.clear(); in_test_output = false; @@ -474,59 +464,59 @@ pub fn filter_bazel_test(stdout: &str, stderr: &str) -> String { continue; } - if trimmed.is_empty() { + if stripped.is_empty() { continue; } - // Extract elapsed time before skipping INFO lines - if trimmed.starts_with("INFO:") || NOISE_WITH_TIMESTAMP.is_match(trimmed) { - if let Some(caps) = ELAPSED_TIME.captures(trimmed) { + // Extract elapsed time before skipping INFO/DEBUG lines + if stripped.starts_with("INFO:") || stripped.starts_with("DEBUG:") { + if let Some(caps) = ELAPSED_TIME.captures(stripped) { elapsed = Some(caps[1].to_string()); } continue; } // Strip progress lines: [N / M] ... - if PROGRESS_LINE.is_match(trimmed) { + if PROGRESS_LINE.is_match(stripped) { continue; } // Strip loading/analyzing status - if trimmed.starts_with("Loading:") - || trimmed.starts_with("Analyzing:") - || trimmed.starts_with("Computing main repo mapping:") + if stripped.starts_with("Loading:") + || stripped.starts_with("Analyzing:") + || stripped.starts_with("Computing main repo mapping:") { continue; } // Strip Java notes - if trimmed.starts_with("Note: ") { + if stripped.starts_with("Note: ") { continue; } // Strip target output paths - if trimmed.starts_with("Target //") || trimmed.starts_with("bazel-bin/") { + if stripped.starts_with("Target //") || stripped.starts_with("bazel-bin/") { continue; } // Strip DEBUG lines - if trimmed.starts_with("DEBUG:") { + if stripped.starts_with("DEBUG:") { continue; } // Strip timeout size warnings - if trimmed.starts_with("There were tests whose specified size") { + if stripped.starts_with("There were tests whose specified size") { continue; } // Test result lines: //pkg:test PASSED in 0.3s - if let Some(caps) = TEST_RESULT_LINE.captures(trimmed) { + if let Some(caps) = TEST_RESULT_LINE.captures(stripped) { let status = &caps[2]; match status { "PASSED" => passed += 1, "FAILED" | "TIMEOUT" | "NO STATUS" => { failed += 1; - failed_result_lines.push(trimmed.to_string()); + failed_result_lines.push(stripped.to_string()); } "FLAKY" => passed += 1, // flaky but passed on retry _ => {} @@ -535,42 +525,42 @@ pub fn filter_bazel_test(stdout: &str, stderr: &str) -> String { } // Executed summary line (skip — we produce our own) - if TEST_SUMMARY.is_match(trimmed) { + if TEST_SUMMARY.is_match(stripped) { continue; } // FAIL: lines - if FAIL_LINE.is_match(trimmed) { - fail_blocks.push(trimmed.to_string()); + if FAIL_LINE.is_match(stripped) { + fail_blocks.push(stripped.to_string()); continue; } // Inline test output start - if TEST_OUTPUT_START.is_match(trimmed) { + if TEST_OUTPUT_START.is_match(stripped) { in_test_output = true; - current_output_block.push(trimmed.to_string()); + current_output_block.push(stripped.to_string()); continue; } // ERROR lines - if ERROR_PLAIN.is_match(trimmed) || ERROR_WITH_TIMESTAMP.is_match(trimmed) { - if trimmed.contains("Build did NOT complete successfully") - || trimmed.contains("not all tests passed") + if stripped.starts_with("ERROR:") { + if stripped.contains("Build did NOT complete successfully") + || stripped.contains("not all tests passed") { continue; } - error_lines.push(trimmed.to_string()); + error_lines.push(stripped.to_string()); continue; } // WARNING lines (strip — build noise) - if WARNING_PLAIN.is_match(trimmed) || WARNING_WITH_TIMESTAMP.is_match(trimmed) { + if stripped.starts_with("WARNING:") { continue; } // Indented log paths after FAILED lines (e.g. " /path/to/test.log") // Keep only if we have failures - if trimmed.starts_with('/') && trimmed.ends_with(".log") && failed > 0 { + if stripped.starts_with('/') && stripped.ends_with(".log") && failed > 0 { continue; // skip log paths — we show inline output instead } @@ -731,6 +721,173 @@ pub fn run_test(args: &[String], verbose: u8) -> Result<()> { Ok(()) } +/**********************************************************************/ +/* bazel run */ +/**********************************************************************/ + +/// Filter `bazel run` output. +/// +/// # Arguments +/// +/// * `stdout` - stdout output from `bazel run` (binary's stdout) +/// * `stderr` - stderr output from `bazel run` (build noise + binary's stderr) +/// +/// # Returns +/// +/// The filtered output: build summary + binary output (forwarded verbatim) +/// +/// # Notes +/// +/// `bazel run` builds a target then executes it. The build phase produces +/// noise on stderr identical to `bazel build`. After building, bazel prints +/// a sentinel line `INFO: Running command line: ...` then exec's the binary. +/// Everything after the sentinel in stderr is the binary's stderr output. +/// All of stdout is the binary's stdout (bazel writes nothing to stdout). +/// +/// This filter splits stderr at the sentinel, applies `filter_bazel_build` +/// to the build phase, then appends the binary's output verbatim. +/// +pub fn filter_bazel_run(stdout: &str, stderr: &str, args: &[String]) -> String { + // Split stderr at the sentinel line, collecting warnings separately + let mut build_stderr = String::new(); + let mut build_warnings: Vec = Vec::new(); + let mut binary_stderr = String::new(); + let mut found_sentinel = false; + let mut has_errors = false; + + for line in stderr.lines() { + let stripped = strip_timestamp(line.trim()); + if !found_sentinel { + if RUN_SENTINEL.is_match(stripped) { + found_sentinel = true; + continue; + } + // Collect warnings separately — only include if build has errors + if stripped.starts_with("WARNING:") { + build_warnings.push(line.to_string()); + continue; + } + if stripped.starts_with("ERROR:") { + has_errors = true; + } + build_stderr.push_str(line); + build_stderr.push('\n'); + } else { + // Drop trailing bazel INFO/WARNING/DEBUG lines after sentinel + if stripped.starts_with("INFO:") + || stripped.starts_with("WARNING:") + || stripped.starts_with("DEBUG:") + { + continue; + } + binary_stderr.push_str(line); + binary_stderr.push('\n'); + } + } + + // Re-inject warnings if build had errors (they provide context) + if has_errors { + for w in &build_warnings { + build_stderr.push_str(w); + build_stderr.push('\n'); + } + } + + // Filter the build phase using existing filter_bazel_build + let build_summary = filter_bazel_build("", &build_stderr); + + // Combine binary output: stdout + post-sentinel stderr + let mut binary_output = String::new(); + let stdout_trimmed = stdout.trim(); + let stderr_trimmed = binary_stderr.trim(); + if !stdout_trimmed.is_empty() { + binary_output.push_str(stdout_trimmed); + } + if !stderr_trimmed.is_empty() { + if !binary_output.is_empty() { + binary_output.push('\n'); + } + binary_output.push_str(stderr_trimmed); + } + + // Format output based on build result + let build_clean = build_summary.starts_with('\u{2713}'); + + if binary_output.is_empty() { + // No binary output — show build summary only + build_summary + } else if build_clean { + // Clean build — skip build summary, just show binary output + binary_output + } else { + // Build had warnings/errors — show both sections + let run_header = format!( + "\n\nbazel run {}\n═══════════════════════════════════════", + args.join(" ") + ); + format!("{}{}\n{}", build_summary, run_header, binary_output) + } +} + +/// Run `bazel run` while filtering the build output. +/// +/// # Arguments +/// +/// * `args` - Arguments to pass to `bazel run` +/// * `verbose` - Verbosity level +/// +/// # Returns +/// +/// Result of the operation +/// +pub fn run_run(args: &[String], verbose: u8) -> Result<()> { + let timer = tracking::TimedExecution::start(); + + let mut cmd = Command::new("bazel"); + cmd.arg("run"); + + for arg in args { + cmd.arg(arg); + } + + if verbose > 0 { + eprintln!("Running: bazel run {}", args.join(" ")); + } + + let output = cmd + .output() + .context("Failed to run bazel run. Is Bazel 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_bazel_run(&stdout, &stderr, args); + + if let Some(hint) = crate::tee::tee_and_hint(&raw, "bazel_run", exit_code) { + println!("{}\n{}", filtered, hint); + } else { + println!("{}", filtered); + } + + timer.track( + &format!("bazel run {}", args.join(" ")), + &format!("rtk bazel run {}", args.join(" ")), + &raw, + &filtered, + ); + + if !output.status.success() { + std::process::exit(exit_code); + } + + Ok(()) +} + /**********************************************************************/ /* bazel query */ /**********************************************************************/ @@ -1010,12 +1167,12 @@ pub fn filter_bazel_query( // Collect ERROR lines from stderr for line in stderr.lines() { - let trimmed = line.trim(); - if trimmed.is_empty() { + let stripped = strip_timestamp(line.trim()); + if stripped.is_empty() { continue; } - if ERROR_WITH_TIMESTAMP.is_match(trimmed) || ERROR_PLAIN.is_match(trimmed) { - result.push_str(trimmed); + if stripped.starts_with("ERROR:") { + result.push_str(stripped); result.push('\n'); } } @@ -2095,4 +2252,310 @@ Executed 1 out of 1 test: 1 tests pass."; assert!(!result.contains("--test_verbose_timeout_warnings")); assert!(result.contains("\u{2713} bazel test: 1 passed, 0 failed")); } + + /******************************************************************/ + /* bazel run tests */ + /******************************************************************/ + fn brun(stdout: &str, stderr: &str) -> String { + brun_with_args(stdout, stderr, &[]) + } + + fn brun_with_args(stdout: &str, stderr: &str, args: &[String]) -> String { + filter_bazel_run(stdout, stderr, args) + } + + #[test] + fn test_filter_bazel_run_success() { + let stderr = "\ +Computing main repo mapping: +Loading: +Loading: 0 packages loaded +Analyzing: target //src:my_binary (6 packages loaded) +INFO: Analyzed target //src:my_binary (81 packages loaded, 684 targets configured). +[0 / 4] [Prepa] BazelWorkspaceStatusAction stable-status.txt +[10 / 14] Compiling src/main.cc +INFO: Found 1 target... +Target //src:my_binary up-to-date: + bazel-bin/src/my_binary +INFO: Elapsed time: 3.50s, Critical Path: 2.10s +INFO: 123 processes: 3 internal, 120 processwrapper-sandbox. +INFO: Build completed successfully, 123 total actions +INFO: Running command line: bazel-bin/src/my_binary +binary stderr line"; + let stdout = "Hello from binary!\nResult: 42"; + let args: Vec = vec!["//src:my_binary".into()]; + let result = brun_with_args(stdout, stderr, &args); + + // Clean build — no build summary, just binary output + assert!(!result.contains("bazel build")); + assert!(!result.contains("═══════════════════════════════════════")); + assert!(result.contains("Hello from binary!")); + assert!(result.contains("Result: 42")); + assert!(result.contains("binary stderr line")); + // Noise stripped + assert!(!result.contains("Loading:")); + assert!(!result.contains("[10 / 14]")); + assert!(!result.contains("INFO:")); + assert!(!result.contains("Computing main repo")); + } + + #[test] + fn test_filter_bazel_run_warnings_stripped() { + let stderr = "\ +WARNING: /home/user/BUILD:10:5: select() on cpu is deprecated. +WARNING: /home/user/BUILD:20:5: another deprecation warning. +INFO: Analyzed target //src:app (10 packages loaded). +[5 / 10] Compiling something.cc +INFO: Found 1 target... +Target //src:app up-to-date: + bazel-bin/src/app +INFO: Build completed successfully, 100 total actions +INFO: Running command line: bazel-bin/src/app +app output here"; + let stdout = "app stdout"; + let result = brun(stdout, stderr); + + // Warnings stripped — clean build, no build section + assert!(!result.contains("WARNING:")); + assert!(!result.contains("select() on cpu")); + assert!(!result.contains("bazel build")); + // Binary output only + assert!(result.contains("app stdout")); + assert!(result.contains("app output here")); + } + + #[test] + fn test_filter_bazel_run_build_error() { + let stderr = "\ +Loading: +WARNING: Target pattern parsing failed. +ERROR: Skipping '//src:nonexistent': no such target '//src:nonexistent' +ERROR: no such target '//src:nonexistent': target 'nonexistent' not declared +INFO: Elapsed time: 0.142s +INFO: 0 processes. +ERROR: Build did NOT complete successfully"; + let result = brun("", stderr); + + assert!(result.contains("bazel build: 2 errors, 1 warning")); + assert!(result.contains("ERROR: Skipping")); + assert!(result.contains("ERROR: no such target")); + assert!(result.contains("WARNING: Target pattern parsing failed")); + assert!(!result.contains("Build did NOT complete successfully")); + // No binary output + assert!(!result.contains("Running command line")); + } + + #[test] + fn test_filter_bazel_run_build_error_no_warnings() { + let stderr = "\ +Loading: +ERROR: Skipping '//src:nonexistent': no such target '//src:nonexistent' +INFO: Elapsed time: 0.142s +INFO: 0 processes. +ERROR: Build did NOT complete successfully"; + let result = brun("", stderr); + + assert!(result.contains("bazel build: 1 error, 0 warnings")); + assert!(result.contains("ERROR: Skipping")); + assert!(!result.contains("WARNING:")); + assert!(!result.contains("Build did NOT complete successfully")); + } + + #[test] + fn test_filter_bazel_run_binary_stderr() { + let stderr = "\ +INFO: Analyzed target //src:app (0 packages loaded). +INFO: Found 1 target... +INFO: Build completed successfully, 50 total actions +INFO: Running command line: bazel-bin/src/app +Error: could not connect to database +Stack trace: + at main.cc:42 + at db.cc:100"; + let result = brun("", stderr); + + // Clean build — no build summary + assert!(!result.contains("bazel build")); + assert!(result.contains("Error: could not connect to database")); + assert!(result.contains("Stack trace:")); + assert!(result.contains("at main.cc:42")); + } + + #[test] + fn test_filter_bazel_run_no_sentinel() { + // No sentinel = build-only, no binary ran (e.g. build phase completed but no run) + let stderr = "\ +INFO: Analyzed target //src:app (10 packages loaded). +[5 / 10] Compiling something.cc +INFO: Found 1 target... +Target //src:app up-to-date: + bazel-bin/src/app +INFO: Build completed successfully, 100 total actions"; + let result = brun("", stderr); + + // Falls back to filter_bazel_build behavior + assert!(result.contains("\u{2713} bazel build (100 actions)")); + } + + #[test] + fn test_filter_bazel_run_strips_build_noise() { + let stderr = "\ +Computing main repo mapping: +Loading: +Loading: 1 packages loaded +Analyzing: target //src:app (6 packages loaded) +DEBUG: /some/debug/info +Note: Some input files use deprecated API. +[0 / 4] [Prepa] BazelWorkspaceStatusAction +[100 / 200] Compiling something.cc +Target //src:app up-to-date: + bazel-bin/src/app +INFO: Elapsed time: 5.00s +INFO: 200 processes: 3 internal, 197 processwrapper-sandbox. +INFO: Build completed successfully, 200 total actions +INFO: Running command line: bazel-bin/src/app"; + let stdout = "output"; + let result = brun(stdout, stderr); + + assert!(!result.contains("Computing main repo")); + assert!(!result.contains("Loading:")); + assert!(!result.contains("Analyzing:")); + assert!(!result.contains("DEBUG:")); + assert!(!result.contains("Note:")); + assert!(!result.contains("[0 / 4]")); + assert!(!result.contains("[100 / 200]")); + assert!(!result.contains("Target //src:app")); + assert!(!result.contains("bazel-bin/src/app")); + assert!(!result.contains("INFO:")); + assert!(result.contains("output")); + } + + #[test] + fn test_filter_bazel_run_empty() { + let result = brun("", ""); + assert_eq!(result, "\u{2713} bazel build (0 actions)"); + } + + #[test] + fn test_filter_bazel_run_token_savings() { + let stderr = "\ +Computing main repo mapping: +Loading: +Loading: 0 packages loaded +Analyzing: target //src:my_binary (6 packages loaded, 6 targets configured) +Analyzing: target //src:my_binary (6 packages loaded, 6 targets configured) +INFO: Analyzed target //src:my_binary (563 packages loaded, 24852 targets configured). +[1 / 1] no actions running +[889 / 4,978] Compiling absl/numeric/int128.cc; 0s processwrapper-sandbox ... (256 actions, 255 running) +[1,084 / 4,978] Compiling absl/time/internal/cctz/src/time_zone_info.cc; 1s processwrapper-sandbox ... (256 actions, 255 running) +[1,191 / 4,978] Compiling tools/cpp/modules_tools/common/common.cc; 2s processwrapper-sandbox ... (256 actions, 255 running) +[1,348 / 4,978] Executing genrule //src:embedded_jdk; 3s processwrapper-sandbox +[1,469 / 4,978] Executing genrule //src:embedded_jdk; 4s processwrapper-sandbox +[1,540 / 4,978] Executing genrule //src:embedded_jdk; 6s processwrapper-sandbox +[4,976 / 4,978] Executing genrule //src:package-zip; 1s processwrapper-sandbox +INFO: Found 1 target... +Target //src:my_binary up-to-date: + bazel-bin/src/my_binary +INFO: Elapsed time: 54.859s, Critical Path: 49.98s +INFO: 2391 processes: 3 internal, 1537 processwrapper-sandbox, 881 worker. +INFO: Build completed successfully, 2391 total actions +INFO: Running command line: bazel-bin/src/my_binary +Hello World"; + + let input_tokens = count_tokens(stderr); + let result = brun("", stderr); + let output_tokens = count_tokens(&result); + + let savings = 100.0 - (output_tokens as f64 / input_tokens as f64 * 100.0); + assert!( + savings >= 60.0, + "Bazel run filter: expected ≥60% savings, got {:.1}% ({} → {} tokens)", + savings, + input_tokens, + output_tokens + ); + } + + #[test] + fn test_filter_bazel_run_timestamp_sentinel() { + let stderr = "\ +(10:23:45) INFO: Analyzed target //src:app (10 packages loaded). +(10:23:46) INFO: Found 1 target... +(10:23:47) INFO: Build completed successfully, 50 total actions +(10:23:48) INFO: Running command line: bazel-bin/src/app +binary output on stderr"; + let stdout = "binary output on stdout"; + let result = brun(stdout, stderr); + + // Clean build — no build summary + assert!(!result.contains("bazel build")); + assert!(result.contains("binary output on stdout")); + assert!(result.contains("binary output on stderr")); + // Sentinel itself should not appear + assert!(!result.contains("Running command line")); + } + + #[test] + #[test] + fn test_filter_bazel_run_real_world_output() { + // Realistic output from `bazel run` with timestamped lines, env-prefixed + // sentinel, and trailing INFO after the sentinel + let stderr = "\ +(17:17:06) WARNING: some build config deprecation warning +(17:17:06) INFO: Current date is 2026-03-02 +(17:17:06) Computing main repo mapping: +(17:17:06) Loading: +(17:17:06) Loading: 0 packages loaded +(17:17:06) Analyzing: target //src/tools/my_tool:my_tool (0 packages loaded, 0 targets configured) +[0 / 1] checking cached actions +(17:17:06) INFO: Analyzed target //src/tools/my_tool:my_tool (0 packages loaded, 0 targets configured). +(17:17:06) INFO: Found 1 target... +Target //src/tools/my_tool:my_tool up-to-date: + bazel-bin/src/tools/my_tool/my_tool +(17:17:06) INFO: Elapsed time: 0.518s, Critical Path: 0.09s +(17:17:06) INFO: 1 process: 3 action cache hit, 1 internal. +(17:17:06) INFO: Build completed successfully, 1 total action +(17:17:06) INFO: +(17:17:06) INFO: Running command line: env FOO=1 BAR=/tmp/cache bazel-bin/src/tools/my_tool/my_tool +(17:17:06) INFO: Some trailing info line"; + let stdout = "Processing input...\nDone."; + let args: Vec = vec![ + "//src/tools/my_tool".into(), + "--".into(), + "\"some-arg\"".into(), + ]; + let result = brun_with_args(stdout, stderr, &args); + + // WARNING stripped — clean build, no build section + assert!(!result.contains("WARNING:")); + assert!(!result.contains("bazel build")); + // Binary output only + assert!(result.contains("Processing input...")); + assert!(result.contains("Done.")); + // All noise stripped + assert!(!result.contains("Computing main repo")); + assert!(!result.contains("Loading:")); + assert!(!result.contains("Analyzing:")); + assert!(!result.contains("[0 / 1]")); + assert!(!result.contains("INFO:")); + assert!(!result.contains("Running command line")); + assert!(!result.contains("FOO=1")); + } + + #[test] + fn test_filter_bazel_run_post_sentinel_info_stripped() { + // Verify that INFO lines after the sentinel are stripped, not forwarded + let stderr = "\ +INFO: Build completed successfully, 10 total actions +INFO: Running command line: bazel-bin/app +INFO: Some trailing info line +INFO: Another trailing info line +actual binary error output"; + let result = brun("binary stdout", stderr); + + assert!(result.contains("binary stdout")); + assert!(result.contains("actual binary error output")); + assert!(!result.contains("Some trailing info")); + assert!(!result.contains("Another trailing info")); + } } diff --git a/src/main.rs b/src/main.rs index 5c6e78f2..d10990e9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -918,6 +918,12 @@ enum BazelCommands { #[arg(trailing_var_arg = true, allow_hyphen_values = true)] args: Vec, }, + /// Run with filtered build noise (binary output forwarded verbatim, 85% build reduction) + Run { + /// Additional bazel run arguments + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, /// Test with compact output (failures only, 85% token reduction) Test { /// Additional bazel test arguments @@ -1561,6 +1567,9 @@ fn main() -> Result<()> { BazelCommands::Build { args } => { bazel_cmd::run_build(&args, cli.verbose)?; } + BazelCommands::Run { args } => { + bazel_cmd::run_run(&args, cli.verbose)?; + } BazelCommands::Test { args } => { bazel_cmd::run_test(&args, cli.verbose)?; } From 8a10c78753c06f6bdd425848fb6ac0021d272217 Mon Sep 17 00:00:00 2001 From: cmolder <28611108+cmolder@users.noreply.github.com> Date: Mon, 2 Mar 2026 22:46:38 -0800 Subject: [PATCH 5/9] Refactor bazel query output aggregation and depth rendering - remove detect-root tuple plumbing and default empty headers to // - replace recursive tree query renderer with output-driven package index - keep external repo grouping and subsection depth semantics - preserve truncation/count behavior and associated bazel query tests Co-authored-by: Codex --- src/bazel_cmd.rs | 1093 ++++++++++++++++++++++++++++++---------------- 1 file changed, 720 insertions(+), 373 deletions(-) diff --git a/src/bazel_cmd.rs b/src/bazel_cmd.rs index f5494e61..fa1220f3 100644 --- a/src/bazel_cmd.rs +++ b/src/bazel_cmd.rs @@ -2,7 +2,7 @@ use crate::tracking; use anyhow::{Context, Result}; use lazy_static::lazy_static; use regex::Regex; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::ffi::OsString; use std::fmt; use std::process::Command; @@ -19,9 +19,9 @@ lazy_static! { /// Matches Bazel target lines /// - /// e.g. "//package/path:target_name", "//:root_target" + /// e.g. "//package/path:target_name", "//:root_target", "@repo//pkg:target" static ref TARGET_LINE: Regex = - Regex::new(r"^(//[^:]*):(.+)$").unwrap(); + Regex::new(r"^((?:@[^/\s:]+)?//[^:]*):(.+)$").unwrap(); /// Matches Bazel progress lines /// @@ -93,7 +93,10 @@ fn strip_timestamp(line: &str) -> &str { /// A limit value that can be a specific number or unlimited ("all"). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Limit { + /// Fixed size N(usize), + + /// Unlimited All, } @@ -755,7 +758,8 @@ pub fn filter_bazel_run(stdout: &str, stderr: &str, args: &[String]) -> String { let mut found_sentinel = false; let mut has_errors = false; - for line in stderr.lines() { + for segment in stderr.split_inclusive('\n') { + let line = segment.strip_suffix('\n').unwrap_or(segment); let stripped = strip_timestamp(line.trim()); if !found_sentinel { if RUN_SENTINEL.is_match(stripped) { @@ -770,18 +774,10 @@ pub fn filter_bazel_run(stdout: &str, stderr: &str, args: &[String]) -> String { if stripped.starts_with("ERROR:") { has_errors = true; } - build_stderr.push_str(line); - build_stderr.push('\n'); + build_stderr.push_str(segment); } else { - // Drop trailing bazel INFO/WARNING/DEBUG lines after sentinel - if stripped.starts_with("INFO:") - || stripped.starts_with("WARNING:") - || stripped.starts_with("DEBUG:") - { - continue; - } - binary_stderr.push_str(line); - binary_stderr.push('\n'); + // Post-sentinel stderr belongs to the executed binary; preserve it verbatim. + binary_stderr.push_str(segment); } } @@ -796,19 +792,10 @@ pub fn filter_bazel_run(stdout: &str, stderr: &str, args: &[String]) -> String { // Filter the build phase using existing filter_bazel_build let build_summary = filter_bazel_build("", &build_stderr); - // Combine binary output: stdout + post-sentinel stderr + // Combine binary output exactly as captured from stdout + post-sentinel stderr. let mut binary_output = String::new(); - let stdout_trimmed = stdout.trim(); - let stderr_trimmed = binary_stderr.trim(); - if !stdout_trimmed.is_empty() { - binary_output.push_str(stdout_trimmed); - } - if !stderr_trimmed.is_empty() { - if !binary_output.is_empty() { - binary_output.push('\n'); - } - binary_output.push_str(stderr_trimmed); - } + binary_output.push_str(stdout); + binary_output.push_str(&binary_stderr); // Format output based on build result let build_clean = build_summary.starts_with('\u{2713}'); @@ -892,152 +879,109 @@ pub fn run_run(args: &[String], verbose: u8) -> Result<()> { /* bazel query */ /**********************************************************************/ -/// Detect the query root from args. -/// Scans for the first `//path/...` pattern and returns `(display_expr, root_path)`. -/// Fallback: `("//...", "//")`. -fn detect_query_root(args: &[String]) -> (String, String) { - for arg in args { - let trimmed = arg.trim_matches('\'').trim_matches('"'); - if trimmed.contains("//") && trimmed.contains("...") { - let root = trimmed.trim_end_matches("..."); - let root = root.trim_end_matches('/'); - let root = if root.is_empty() { "//" } else { root }; - return (trimmed.to_string(), root.to_string()); - } - } - ("//...".to_string(), "//".to_string()) -} - -/// Count path components of a package relative to a root. -/// root="//" package="//src/lib/foo" → 3 (src, lib, foo) -/// root="//src" package="//src/lib/foo" → 2 (lib, foo) -#[cfg(test)] -fn package_depth(root: &str, package: &str) -> usize { - let root_stripped = root.strip_prefix("//").unwrap_or(root); - let pkg_stripped = package.strip_prefix("//").unwrap_or(package); - - let relative = if root_stripped.is_empty() { - pkg_stripped - } else { - pkg_stripped - .strip_prefix(root_stripped) - .unwrap_or(pkg_stripped) - .strip_prefix('/') - .unwrap_or("") - }; - - if relative.is_empty() { - 0 - } else { - relative.split('/').count() - } -} - -/// Extract the relative name of a child package under a parent. -/// parent="//examples", child="//examples/cpp" → "cpp" -/// parent="//", child="//src" → "src" -#[cfg(test)] -fn relative_name(parent: &str, child: &str) -> String { - let parent_stripped = parent.strip_prefix("//").unwrap_or(parent); - let child_stripped = child.strip_prefix("//").unwrap_or(child); - - if parent_stripped.is_empty() { - // root parent, take first component - child_stripped.split('/').next().unwrap_or("").to_string() - } else { - child_stripped - .strip_prefix(parent_stripped) - .unwrap_or(child_stripped) - .strip_prefix('/') - .unwrap_or("") - .split('/') - .next() - .unwrap_or("") - .to_string() - } +#[derive(Debug, Default)] +struct PackageStats { + // Targets defined directly in this package. + direct_targets: Vec, + // Immediate child package names, sorted. + child_names: std::collections::BTreeSet, + // Cumulative targets in this package subtree (including this package). + cumulative_targets: usize, + // Number of descendant packages (excluding this package). + cumulative_packages: usize, + // Maximum descendant depth from this package (0 means no child packages). + max_depth: usize, } -/// A node in the package tree for hierarchical rendering. #[derive(Debug, Default)] -struct TreeNode { - /// Targets directly in this package - targets: Vec, - /// Child package nodes, keyed by their relative name - children: BTreeMap, +struct PackageIndex { + // Keyed by canonical package path like "//", "//src", "//src/main". + nodes: BTreeMap, } -impl TreeNode { - /// Count cumulative targets in entire subtree (including self). - fn cumulative_targets(&self) -> usize { - self.targets.len() - + self - .children - .values() - .map(|c| c.cumulative_targets()) - .sum::() - } - - /// Count cumulative sub-packages in entire subtree (not including self). - fn cumulative_packages(&self) -> usize { - let direct = self.children.len(); - direct - + self - .children - .values() - .map(|c| c.cumulative_packages()) - .sum::() - } -} +/// Build a compact package index from flat package->targets data. +/// +/// This is a single aggregation pass: each package contributes to itself and +/// all ancestor prefixes, so cumulative counts are precomputed for rendering. +fn build_package_index(packages: &BTreeMap>) -> PackageIndex { + let mut index = PackageIndex::default(); + + for (package, targets) in packages { + let stripped = package.strip_prefix("//").unwrap_or(package); + let parts: Vec<&str> = if stripped.is_empty() { + Vec::new() + } else { + stripped.split('/').collect() + }; -/// Build a tree from a flat BTreeMap of packages under a given root. -fn build_tree(packages: &BTreeMap>, root: &str) -> TreeNode { - let mut tree = TreeNode::default(); + let full_path = if parts.is_empty() { + "//".to_string() + } else { + format!("//{}", parts.join("/")) + }; + index + .nodes + .entry(full_path) + .or_default() + .direct_targets + .extend(targets.iter().cloned()); + + let target_count = targets.len(); + for depth in 0..=parts.len() { + let ancestor = if depth == 0 { + "//".to_string() + } else { + format!("//{}", parts[..depth].join("/")) + }; - // Add root's own targets if present - if let Some(targets) = packages.get(root) { - tree.targets = targets.clone(); - } + let stats = index.nodes.entry(ancestor).or_default(); + stats.cumulative_targets += target_count; - // Collect all packages under this root (excluding the root itself) - let root_stripped = root.strip_prefix("//").unwrap_or(root); + let relative_depth = parts.len().saturating_sub(depth); + stats.max_depth = stats.max_depth.max(relative_depth); - for (pkg, targets) in packages { - let pkg_stripped = pkg.strip_prefix("//").unwrap_or(pkg); + if depth < parts.len() { + stats.child_names.insert(parts[depth].to_string()); + } + } + } - // Skip the root package itself - if pkg_stripped == root_stripped { - continue; + // Compute descendant package counts from the child graph so intermediate + // prefixes are counted uniformly (matching previous tree semantics). + let mut memo: HashMap = HashMap::new(); + let keys: Vec = index.nodes.keys().cloned().collect(); + for key in keys { + let count = cumulative_packages_for(&index, &key, &mut memo); + if let Some(stats) = index.nodes.get_mut(&key) { + stats.cumulative_packages = count; } + } - // Check if this package is under the root - let relative = if root_stripped.is_empty() { - if pkg_stripped.is_empty() { - continue; - } - pkg_stripped.to_string() - } else if let Some(rest) = pkg_stripped.strip_prefix(root_stripped) { - if let Some(rest) = rest.strip_prefix('/') { - rest.to_string() - } else { - continue; - } - } else { - continue; - }; + index +} - // Walk the path components and insert into tree - let parts: Vec<&str> = relative.split('/').collect(); - let mut current = &mut tree; +fn cumulative_packages_for( + index: &PackageIndex, + path: &str, + memo: &mut HashMap, +) -> usize { + if let Some(&cached) = memo.get(path) { + return cached; + } - for part in &parts { - current = current.children.entry(part.to_string()).or_default(); - } + let Some(node) = index.nodes.get(path) else { + memo.insert(path.to_string(), 0); + return 0; + }; - // Set targets on the leaf node - current.targets = targets.clone(); + let child_names: Vec = node.child_names.iter().cloned().collect(); + let mut total = child_names.len(); + for child in child_names { + total += cumulative_packages_for(index, &child_path(path, &child), memo); } - tree + memo.insert(path.to_string(), total); + total } /// Format a count label like "5 targets" or "1 target", with optional package count. @@ -1069,22 +1013,25 @@ fn format_counts(target_count: usize, package_count: usize) -> String { } } -/// Render a tree node's children at a given depth, with indentation. -fn render_tree( - node: &TreeNode, - max_depth: usize, +fn child_path(parent: &str, child: &str) -> String { + if parent == "//" { + format!("//{}", child) + } else { + format!("{}/{}", parent, child) + } +} + +/// Render one section body: immediate child packages, then immediate targets. +fn render_section_body( + index: &PackageIndex, + section_path: &str, width: usize, - current_depth: usize, result: &mut String, ) { - if current_depth >= max_depth { - return; - } - - let indent = " ".repeat(current_depth); - - let child_count = node.children.len(); - let target_count = node.targets.len(); + let empty = PackageStats::default(); + let node = index.nodes.get(section_path).unwrap_or(&empty); + let child_count = node.child_names.len(); + let target_count = node.direct_targets.len(); // Width budget: sub-packages first, then targets let pkg_slots = width.min(child_count); @@ -1095,25 +1042,24 @@ fn render_tree( let hidden_targets = target_count.saturating_sub(target_slots); // Render sub-packages - for (i, (name, child)) in node.children.iter().enumerate() { + for (i, name) in node.child_names.iter().enumerate() { if i >= pkg_slots { break; } - let cum_targets = child.cumulative_targets(); - let cum_packages = child.cumulative_packages(); + let child_key = child_path(section_path, name); + let child_stats = index.nodes.get(&child_key).unwrap_or(&empty); + let cum_targets = child_stats.cumulative_targets; + let cum_packages = child_stats.cumulative_packages; let counts = format_counts(cum_targets, cum_packages); - result.push_str(&format!("{}📦 {} ({})\n", indent, name, counts)); - - // Recurse into child if within depth - render_tree(child, max_depth, width, current_depth + 1, result); + result.push_str(&format!("📦 {} ({})\n", name, counts)); } // Render targets - for (i, target) in node.targets.iter().enumerate() { + for (i, target) in node.direct_targets.iter().enumerate() { if i >= target_slots { break; } - result.push_str(&format!("{}🎯 :{}\n", indent, target)); + result.push_str(&format!("🎯 :{}\n", target)); } // Truncation line @@ -1133,37 +1079,201 @@ fn render_tree( if hidden_targets == 1 { "" } else { "s" } )); } - result.push_str(&format!("{}(+{})\n", indent, parts.join(", "))); + result.push_str(&format!("(+{})\n", parts.join(", "))); } } -/// Filter `bazel query` output. -/// -/// # Arguments -/// -/// * `stdout` - stdout output from `bazel query` -/// * `stderr` - stderr output from `bazel query` -/// * `depth` - Maxmimum depth of the package tree to show -/// * `width` - Maximum number of items to show for each package -/// * `root` - (`display_expr`, `root_path`) from detect_query_root -/// -/// # Returns -/// -/// The filtered `bazel query` output -/// -/// # Notes -/// -/// * `depth` and `width` can be set to [`usize::MAX`] to disable -/// truncation. -/// -pub fn filter_bazel_query( - stdout: &str, - stderr: &str, +fn render_query_section( + result: &mut String, + packages: &BTreeMap>, depth: usize, width: usize, - root: &(String, String), -) -> String { + header_label: &str, + root_path: &str, + external_repo: Option<&str>, +) { + let index = build_package_index(packages); + let empty = PackageStats::default(); + let root_node = index.nodes.get(root_path).unwrap_or(&empty); + + let effective_depth = depth.min(root_node.max_depth.saturating_add(1)); + if effective_depth <= 1 { + let total_targets = root_node.cumulative_targets; + let total_packages = root_node.cumulative_packages; + let counts = format_counts(total_targets, total_packages); + result.push_str(&format!("{} ({})\n", header_label, counts)); + render_section_body(&index, root_path, width, result); + return; + } + + let mut sections: Vec = Vec::new(); + collect_section_nodes(&index, root_path, 0, effective_depth, &mut sections); + + let mut rendered_sections = 0usize; + for section in §ions { + let stats = index.nodes.get(§ion.path).unwrap_or(&empty); + let is_leaf_section = section.level + 1 == effective_depth; + let target_count = if is_leaf_section { + stats.cumulative_targets + } else { + stats.direct_targets.len() + }; + let package_count = if is_leaf_section { + stats.cumulative_packages + } else { + 0 + }; + + // Skip empty intermediate headers (or any section with no visible content). + if target_count == 0 && package_count == 0 { + continue; + } + + if rendered_sections > 0 { + result.push('\n'); + } + let counts = format_counts(target_count, package_count); + let label = format_query_section_label(§ion.path, external_repo); + result.push_str(&format!("{} ({})\n", label, counts)); + + if is_leaf_section { + // At the final expanded depth, show one level of package/target items. + render_section_body(&index, §ion.path, width, result); + } else { + render_targets_only(&index, §ion.path, width, result); + } + rendered_sections += 1; + } +} + +/// Find the deepest shared package prefix across all package keys. +/// +/// Returns a `//`-prefixed path. If there is no shared non-root prefix, +/// returns `"//"`. +fn common_package_prefix(packages: &BTreeMap>) -> String { + let mut shared_parts: Option> = None; + + for package in packages.keys() { + let stripped = package.strip_prefix("//").unwrap_or(package); + let parts: Vec = if stripped.is_empty() { + Vec::new() + } else { + stripped.split('/').map(ToString::to_string).collect() + }; + + match &mut shared_parts { + None => shared_parts = Some(parts), + Some(shared) => { + let common_len = shared + .iter() + .zip(parts.iter()) + .take_while(|(a, b)| a == b) + .count(); + shared.truncate(common_len); + } + } + } + + let shared_parts = shared_parts.unwrap_or_default(); + if shared_parts.is_empty() { + "//".to_string() + } else { + format!("//{}", shared_parts.join("/")) + } +} + +/// Base root for a local package: +/// * `//:x` -> `//` +/// * `//src/foo:bar` -> `//src` +fn local_base_root(package: &str) -> String { + let stripped = package.strip_prefix("//").unwrap_or(package); + if stripped.is_empty() { + "//".to_string() + } else { + let top = stripped.split('/').next().unwrap_or(""); + if top.is_empty() { + "//".to_string() + } else { + format!("//{}", top) + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +enum QuerySectionRoot { + Local(String), // "//", "//src", "//tools", ... + External(String), // repo name without leading '@' +} + +#[derive(Debug)] +struct SectionNode { + path: String, + level: usize, +} + +fn format_query_section_label(path: &str, external_repo: Option<&str>) -> String { + if let Some(repo) = external_repo { + format!("@{}{}", repo, path) + } else { + path.to_string() + } +} + +fn render_targets_only( + index: &PackageIndex, + section_path: &str, + width: usize, + result: &mut String, +) { + let empty = PackageStats::default(); + let node = index.nodes.get(section_path).unwrap_or(&empty); + let target_slots = width.min(node.direct_targets.len()); + let hidden_targets = node.direct_targets.len().saturating_sub(target_slots); + + for target in node.direct_targets.iter().take(target_slots) { + result.push_str(&format!("🎯 :{}\n", target)); + } + + if hidden_targets > 0 { + result.push_str(&format!( + "(+{} more target{})\n", + hidden_targets, + if hidden_targets == 1 { "" } else { "s" } + )); + } +} + +fn collect_section_nodes( + index: &PackageIndex, + path: &str, + level: usize, + max_levels: usize, + out: &mut Vec, +) { + if level >= max_levels { + return; + } + out.push(SectionNode { + path: path.to_string(), + level, + }); + if level + 1 >= max_levels { + return; + } + + let Some(node) = index.nodes.get(path) else { + return; + }; + + for name in &node.child_names { + let next = child_path(path, name); + collect_section_nodes(index, &next, level + 1, max_levels, out); + } +} + +pub fn filter_bazel_query(stdout: &str, stderr: &str, depth: usize, width: usize) -> String { let mut result = String::new(); + let mut has_error_lines = false; // Collect ERROR lines from stderr for line in stderr.lines() { @@ -1172,13 +1282,19 @@ pub fn filter_bazel_query( continue; } if stripped.starts_with("ERROR:") { + has_error_lines = true; result.push_str(stripped); result.push('\n'); } } - // Group targets by package, preserve non-target lines - let mut packages: BTreeMap> = BTreeMap::new(); + // Group targets by output-derived roots: + // - local roots: "//" and "//level0" + // - external roots: "@repo" + let mut local_sections: BTreeMap>> = BTreeMap::new(); + let mut external_sections: BTreeMap>> = BTreeMap::new(); + let mut section_order: Vec = Vec::new(); + let mut seen_sections: HashSet = HashSet::new(); let mut non_target_lines: Vec = Vec::new(); for line in stdout.lines() { @@ -1190,24 +1306,136 @@ pub fn filter_bazel_query( if let Some(caps) = TARGET_LINE.captures(trimmed) { let package = caps[1].to_string(); let target = caps[2].to_string(); - packages.entry(package).or_default().push(target); + + if package.starts_with('@') { + if let Some((repo, rest)) = package.split_once("//") { + let repo = repo.trim_start_matches('@').to_string(); + let relative_package = if rest.is_empty() { + "//".to_string() + } else { + format!("//{}", rest) + }; + external_sections + .entry(repo.clone()) + .or_default() + .entry(relative_package) + .or_default() + .push(target); + + let section = QuerySectionRoot::External(repo); + if seen_sections.insert(section.clone()) { + section_order.push(section); + } + } else { + external_sections + .entry("external".to_string()) + .or_default() + .entry("//".to_string()) + .or_default() + .push(target); + + let section = QuerySectionRoot::External("external".to_string()); + if seen_sections.insert(section.clone()) { + section_order.push(section); + } + } + } else { + let base_root = local_base_root(&package); + local_sections + .entry(base_root.clone()) + .or_default() + .entry(package) + .or_default() + .push(target); + + let section = QuerySectionRoot::Local(base_root); + if seen_sections.insert(section.clone()) { + section_order.push(section); + } + } } else { non_target_lines.push(trimmed.to_string()); } } - let (display_expr, root_path) = root; - let tree = build_tree(&packages, root_path); - - let total_targets = tree.cumulative_targets(); - let total_packages = tree.cumulative_packages(); - let counts = format_counts(total_targets, total_packages); - - // Header line (no emoji) - result.push_str(&format!("{} ({})\n", display_expr, counts)); + if local_sections.is_empty() && external_sections.is_empty() { + // If bazel query failed and only error lines are present, do not add + // a synthetic empty target header. + if !has_error_lines { + render_query_section( + &mut result, + &BTreeMap::new(), + depth, + width, + "//", + "//", + None, + ); + } + } else { + let mut rendered_sections = 0usize; + + for section in section_order { + let rendered = match section { + QuerySectionRoot::Local(base_root) => { + if let Some(packages) = local_sections.get(&base_root) { + let shared_root = common_package_prefix(packages); + let section_root = if shared_root == "//" { + base_root + } else { + shared_root + }; + let section_display = section_root.clone(); + if rendered_sections > 0 { + result.push('\n'); + } + render_query_section( + &mut result, + packages, + depth, + width, + §ion_display, + §ion_root, + None, + ); + true + } else { + false + } + } + QuerySectionRoot::External(repo) => { + if let Some(packages) = external_sections.get(&repo) { + let shared_root = common_package_prefix(packages); + let (section_display, section_root) = if shared_root == "//" { + (format!("@{}//", repo), "//".to_string()) + } else { + let suffix = shared_root.strip_prefix("//").unwrap_or(&shared_root); + (format!("@{}//{}", repo, suffix), shared_root) + }; + if rendered_sections > 0 { + result.push('\n'); + } + render_query_section( + &mut result, + packages, + depth, + width, + §ion_display, + §ion_root, + Some(&repo), + ); + true + } else { + false + } + } + }; - // Render children - render_tree(&tree, depth, width, 0, &mut result); + if rendered { + rendered_sections += 1; + } + } + } // Output non-target lines for line in &non_target_lines { @@ -1234,8 +1462,6 @@ pub fn filter_bazel_query( pub fn run_query(args: &[String], depth: Limit, width: Limit, verbose: u8) -> Result<()> { let timer = tracking::TimedExecution::start(); - let root = detect_query_root(args); - let mut cmd = Command::new("bazel"); cmd.arg("query"); @@ -1259,10 +1485,14 @@ pub fn run_query(args: &[String], depth: Limit, width: Limit, verbose: u8) -> Re .status .code() .unwrap_or(if output.status.success() { 0 } else { 1 }); - let filtered = filter_bazel_query(&stdout, &stderr, depth.value(), width.value(), &root); + let filtered = filter_bazel_query(&stdout, &stderr, depth.value(), width.value()); - if let Some(hint) = crate::tee::tee_and_hint(&raw, "bazel_query", exit_code) { - println!("{}\n{}", filtered, hint); + if output.status.success() { + if let Some(hint) = crate::tee::tee_and_hint(&raw, "bazel_query", exit_code) { + println!("{}\n{}", filtered, hint); + } else { + println!("{}", filtered); + } } else { println!("{}", filtered); } @@ -1632,12 +1862,8 @@ ERROR: Build did NOT complete successfully"; /******************************************************************/ /* bazel query tests */ /******************************************************************/ - fn default_root() -> (String, String) { - ("//...".to_string(), "//".to_string()) - } - fn query(stdout: &str, stderr: &str, depth: usize, width: usize) -> String { - filter_bazel_query(stdout, stderr, depth, width, &default_root()) + filter_bazel_query(stdout, stderr, depth, width) } #[test] @@ -1652,54 +1878,6 @@ ERROR: Build did NOT complete successfully"; assert_eq!(Limit::All.to_string(), "all"); } - #[test] - fn test_detect_query_root() { - // Single //... - let root = detect_query_root(&["//...".to_string()]); - assert_eq!(root, ("//...".to_string(), "//".to_string())); - - // Subpath - let root = detect_query_root(&["//examples/...".to_string()]); - assert_eq!( - root, - ("//examples/...".to_string(), "//examples".to_string()) - ); - - // Quoted args - let root = detect_query_root(&["'//src/...'".to_string()]); - assert_eq!(root, ("//src/...".to_string(), "//src".to_string())); - - // No match → fallback - let root = detect_query_root(&["--keep_going".to_string()]); - assert_eq!(root, ("//...".to_string(), "//".to_string())); - - // Multiple args → takes first match - let root = detect_query_root(&["--keep_going".to_string(), "//host/...".to_string()]); - assert_eq!(root, ("//host/...".to_string(), "//host".to_string())); - } - - #[test] - fn test_package_depth() { - assert_eq!(package_depth("//", "//src"), 1); - assert_eq!(package_depth("//", "//src/lib"), 2); - assert_eq!(package_depth("//", "//src/lib/foo"), 3); - assert_eq!(package_depth("//", "//"), 0); - assert_eq!(package_depth("//src", "//src"), 0); - assert_eq!(package_depth("//src", "//src/lib"), 1); - assert_eq!(package_depth("//src", "//src/lib/foo"), 2); - } - - #[test] - fn test_relative_name() { - assert_eq!(relative_name("//", "//src"), "src"); - assert_eq!(relative_name("//", "//src/lib"), "src"); - assert_eq!(relative_name("//examples", "//examples/cpp"), "cpp"); - assert_eq!( - relative_name("//examples", "//examples/java-native"), - "java-native" - ); - } - #[test] fn test_strips_info_warning_noise() { let stderr = "\ @@ -1741,7 +1919,7 @@ ERROR: another error"; fn test_empty_output() { let result = query("", "", usize::MAX, usize::MAX); // With default root, header is still produced - assert!(result.contains("//... (0 targets)")); + assert!(result.contains("// (0 targets)")); } #[test] @@ -1772,8 +1950,8 @@ some non-target output line //tools:c"; let result = query(stdout, "", usize::MAX, usize::MAX); - // Header has cumulative totals, no emoji - assert!(result.starts_with("//... (3 targets, 3 packages)")); + assert!(result.contains("//src/lib (2 targets)")); + assert!(result.contains("//tools (1 target)")); } #[test] @@ -1788,15 +1966,10 @@ some non-target output line //:root_target"; let result = query(stdout, "", 1, usize::MAX); - // Depth 1: should show src, tools as 📦 with cumulative counts - assert!(result.contains("📦 src (3 targets, 2 packages)")); - assert!(result.contains("📦 tools (3 targets, 1 package)")); - // Root target shown as 🎯 + assert!(result.contains("//src (3 targets, 2 packages)")); + assert!(result.contains("//tools/gen (3 targets)")); + assert!(result.contains("// (1 target)")); assert!(result.contains("🎯 :root_target")); - // Should NOT show children (lib, app, gen) - assert!(!result.contains("📦 lib")); - assert!(!result.contains("📦 app")); - assert!(!result.contains("📦 gen")); } #[test] @@ -1809,15 +1982,9 @@ some non-target output line //tools:e"; let result = query(stdout, "", 2, usize::MAX); - // Level 1: src, tools visible - assert!(result.contains("📦 src (4 targets, 4 packages)")); - assert!(result.contains("📦 tools (1 target)")); - // Level 2: lib and app visible under src with relative names - assert!(result.contains(" 📦 lib (3 targets, 2 packages)")); - assert!(result.contains(" 📦 app (1 target)")); - // Level 3 (math, io) NOT expanded - assert!(!result.contains(" 📦 math")); - assert!(!result.contains(" 📦 io")); + assert!(result.contains("//src/app (1 target)")); + assert!(result.contains("//src/lib (3 targets, 2 packages)")); + assert!(result.contains("//tools (1 target)")); } #[test] @@ -1828,16 +1995,12 @@ some non-target output line //src/app:c"; let result = query(stdout, "", usize::MAX, usize::MAX); - // All levels visible - assert!(result.contains("📦 src")); - assert!(result.contains(" 📦 lib")); - assert!(result.contains(" 📦 math")); - assert!(result.contains(" 📦 io")); - assert!(result.contains(" 📦 app")); - // Leaf targets shown - assert!(result.contains(" 🎯 :a")); - assert!(result.contains(" 🎯 :b")); - assert!(result.contains(" 🎯 :c")); + assert!(result.contains("//src/app (1 target)")); + assert!(result.contains("//src/lib/io (1 target)")); + assert!(result.contains("//src/lib/math (1 target)")); + assert!(result.contains("🎯 :a")); + assert!(result.contains("🎯 :b")); + assert!(result.contains("🎯 :c")); } #[test] @@ -1850,53 +2013,44 @@ some non-target output line //examples/java/sub:d"; let result = query(stdout, "", 2, usize::MAX); - // examples shows cumulative: 4 targets, 4 packages (cpp, go, java, sub) - // Note: java/sub is counted as an additional package node in the tree - assert!(result.contains("📦 examples (4 targets, 4 packages)")); - // Children are expanded but parent still shows full counts - assert!(result.contains(" 📦 cpp (2 targets)")); - assert!(result.contains(" 📦 go (1 target)")); - assert!(result.contains(" 📦 java (1 target, 1 package)")); + assert!(result.contains("//examples/cpp (2 targets)")); + assert!(result.contains("//examples/go (1 target)")); + assert!(result.contains("//examples/java (1 target, 1 package)")); } #[test] fn test_width_budget_packages_then_targets() { - // Width 5: 3 sub-packages take 3 slots, 2 remaining for targets let stdout = "\ -//src:a -//src:b -//src:c -//src:d -//tools:e -//lib:f -//:root_a -//:root_b -//:root_c"; +//root/a:t +//root/b:t +//root/c:t +//root/d:t +//root:root_a +//root:root_b +//root:root_c"; let result = query(stdout, "", 1, 5); - // 3 sub-packages use 3 slots - assert!(result.contains("📦 lib")); - assert!(result.contains("📦 src")); - assert!(result.contains("📦 tools")); - // 2 remaining slots for targets + assert!(result.contains("//root (7 targets, 4 packages)")); + assert!(result.contains("📦 a (1 target)")); + assert!(result.contains("📦 b (1 target)")); + assert!(result.contains("📦 c (1 target)")); + assert!(result.contains("📦 d (1 target)")); assert!(result.contains("🎯 :root_a")); - assert!(result.contains("🎯 :root_b")); - // Third target hidden - assert!(!result.contains("🎯 :root_c")); - assert!(result.contains("(+1 more target)")); + assert!(!result.contains("🎯 :root_b")); + assert!(result.contains("(+2 more targets)")); } #[test] fn test_width_limits_packages() { let stdout = "\ -//a:t1 -//b:t2 -//c:t3 -//d:t4 -//e:t5"; +//root/a:t1 +//root/b:t2 +//root/c:t3 +//root/d:t4 +//root/e:t5"; let result = query(stdout, "", 1, 3); - // Only 3 packages shown (BTreeMap order: a, b, c) + assert!(result.contains("//root (5 targets, 5 packages)")); assert!(result.contains("📦 a")); assert!(result.contains("📦 b")); assert!(result.contains("📦 c")); @@ -1907,33 +2061,28 @@ some non-target output line #[test] fn test_condensed_truncation_line() { - // Both packages and targets truncated let stdout = "\ -//a:t -//b:t -//c:t -//d:t -//:x -//:y -//:z"; +//root/a:t +//root/b:t +//root/c:t +//root/d:t +//root:x +//root:y +//root:z"; let result = query(stdout, "", 1, 3); - // 3 width: 3 packages shown (a, b, c), d hidden, targets use 0 slots - // All 3 root targets hidden assert!(result.contains("(+1 more sub-package, 3 more targets)")); } #[test] fn test_condensed_truncation_omits_zero_parts() { - // Only packages truncated, no targets let stdout = "\ -//a:t -//b:t -//c:t -//d:t"; +//root/a:t +//root/b:t +//root/c:t +//root/d:t"; let result = query(stdout, "", 1, 3); - // 3 packages shown, 1 hidden, no root targets assert!(result.contains("(+1 more sub-package)")); assert!(!result.contains("more target")); } @@ -1946,11 +2095,10 @@ some non-target output line //src:lib"; let result = query(stdout, "", 1, usize::MAX); - // Root targets shown as 🎯 at top level + assert!(result.contains("//src (1 target)")); + assert!(result.contains("// (2 targets)")); assert!(result.contains("🎯 :bazel-distfile")); assert!(result.contains("🎯 :bazel-srcs")); - // Sub-package shown as 📦 - assert!(result.contains("📦 src")); } #[test] @@ -1960,11 +2108,8 @@ some non-target output line //examples/go:b"; let result = query(stdout, "", 2, usize::MAX); - // Children show relative names (cpp, go), not full path - assert!(result.contains(" 📦 cpp")); - assert!(result.contains(" 📦 go")); - assert!(!result.contains("examples/cpp")); - assert!(!result.contains("examples/go")); + assert!(result.contains("//examples/cpp (1 target)")); + assert!(result.contains("//examples/go (1 target)")); } #[test] @@ -2018,14 +2163,187 @@ some non-target output line assert!(!result.contains("Invocation ID")); assert!(!result.contains("Elapsed time")); - // Header with total count - assert!(result.contains("//... (16 targets, 4 packages)")); + assert!(result.contains("//src/app/foo/bar (16 targets)")); - // All 16 targets should be present (depth=all) assert!(result.contains("🎯 :bar\n")); assert!(result.contains("🎯 :runner_test")); } + #[test] + fn test_filter_bazel_query_multi_root_no_target_loss() { + let stdout = "\ +//src/app:bin +//tools/gen:tool +//third_party/lib:pkg"; + let result = filter_bazel_query(stdout, "", usize::MAX, usize::MAX); + + assert!(result.contains("//src/app (1 target)")); + assert!(result.contains("//tools/gen (1 target)")); + assert!(result.contains("//third_party/lib (1 target)")); + assert!(result.contains("🎯 :bin")); + assert!(result.contains("🎯 :tool")); + assert!(result.contains("🎯 :pkg")); + } + + #[test] + fn test_filter_bazel_query_multi_root_respects_width() { + let stdout = "\ +//src/s1:a +//src/s2:b +//tools/t1:c +//tools/t2:d"; + let result = filter_bazel_query(stdout, "", 1, 1); + + assert!( + result.contains("//src (2 targets"), + "unexpected output:\n{}", + result + ); + assert!( + result.contains("//tools (2 targets"), + "unexpected output:\n{}", + result + ); + // Width 1 at each section root: one child package shown, one hidden. + assert_eq!(result.matches("(+1 more sub-package)").count(), 2); + assert!(result.contains("📦 s1 (1 target)")); + assert!(result.contains("📦 t1 (1 target)")); + } + + #[test] + fn test_filter_bazel_query_groups_external_repos() { + let stdout = "\ +//src/app:bin +@abseil-cpp//absl/base:core_headers +@abseil-cpp//absl/strings:str_format +@zlib//:zlib"; + let result = filter_bazel_query(stdout, "", 1, 10); + + assert!(result.contains("//src/app (1 target)")); + assert!(result.contains("@abseil-cpp//absl (2 targets")); + assert!(result.contains("@zlib// (1 target)")); + assert!(result.contains("📦 base (1 target)")); + assert!(result.contains("📦 strings (1 target)")); + assert!(result.contains("🎯 :zlib")); + } + + #[test] + fn test_filter_bazel_query_consolidates_deep_common_prefix() { + let stdout = "\ +//src/java_tools/buildjar:a +//src/java_tools/import_deps_checker:b +//src/java_tools/junitrunner:c"; + let result = filter_bazel_query(stdout, "", 1, 10); + + assert!(result.starts_with("//src/java_tools (3 targets")); + assert!(result.contains("📦 buildjar (1 target)")); + assert!(result.contains("📦 import_deps_checker (1 target)")); + assert!(result.contains("📦 junitrunner (1 target)")); + } + + #[test] + fn test_filter_bazel_query_splits_external_repos_by_repo_root() { + let stdout = "\ +@abseil-cpp//absl/base:core +@abseil-cpp//absl/strings:format +@bazel_skylib//lib:paths +@bazel_skylib//rules:copy"; + let result = filter_bazel_query(stdout, "", 1, 10); + + assert!(result.contains("@abseil-cpp//absl (2 targets")); + assert!(result.contains("@bazel_skylib// (2 targets, 2 packages)")); + assert!(result.contains("📦 base (1 target)")); + assert!(result.contains("📦 strings (1 target)")); + assert!(result.contains("📦 lib (1 target)")); + assert!(result.contains("📦 rules (1 target)")); + } + + #[test] + fn test_filter_bazel_query_external_root_targets_keep_repo_root_header() { + let stdout = "\ +@abseil-cpp//:root_target +@abseil-cpp//absl/base:core"; + let result = filter_bazel_query(stdout, "", 1, 10); + + assert!(result.starts_with("@abseil-cpp// (2 targets, 2 packages)")); + assert!(result.contains("📦 absl (1 target, 1 package)")); + assert!(result.contains("🎯 :root_target")); + } + + #[test] + fn test_filter_bazel_query_depth_1_runtime_mode_stays_single_section() { + let stdout = "\ +//src:root +//src/conditions:a +//src/java_tools:b"; + let result = filter_bazel_query(stdout, "", 1, 10); + + assert!(result.starts_with("//src (3 targets, 2 packages)")); + assert!(result.contains("📦 conditions (1 target)")); + assert!(result.contains("📦 java_tools (1 target)")); + assert!(result.contains("🎯 :root")); + assert!(!result.contains("...")); + } + + #[test] + fn test_filter_bazel_query_depth_2_runtime_mode_expands_to_sections() { + let stdout = "\ +//src:root_a +//src:root_b +//src/conditions:c1 +//src/java_tools:j1 +//src/java_tools/sub:s1"; + let result = filter_bazel_query(stdout, "", 2, 10); + + assert!(result.contains("//src (2 targets)")); + assert!(result.contains("🎯 :root_a")); + assert!(result.contains("🎯 :root_b")); + assert!(result.contains("//src/conditions (1 target)")); + assert!(result.contains("//src/java_tools (2 targets, 1 package)")); + // Depth sections are flat; no tree indentation in this mode. + assert!(!result.contains(" 📦")); + } + + #[test] + fn test_filter_bazel_query_depth_2_skips_empty_intermediate_section() { + let stdout = "\ +@xds+//xds/data/orca:alpha +@xds+//xds/data/orca:beta +@xds+//xds/service/orca:gamma +@xds+//xds/service/orca:delta"; + let result = filter_bazel_query(stdout, "", 2, 10); + + assert!(!result.contains("@xds+//xds (0 targets)")); + assert!(result.contains("@xds+//xds/data")); + assert!(result.contains("@xds+//xds/service")); + } + + #[test] + fn test_filter_bazel_query_error_only_no_empty_header() { + let stderr = + "ERROR: Evaluation of query \"deps(//...)\" failed: preloading transitive closure failed"; + let result = filter_bazel_query("", stderr, usize::MAX, 10); + + assert_eq!( + result, + "ERROR: Evaluation of query \"deps(//...)\" failed: preloading transitive closure failed" + ); + assert!(!result.contains("//... (0 targets)")); + } + + #[test] + fn test_filter_bazel_query_single_root_uses_subsections() { + let stdout = "\ +//src/lib:a +//src/app:b"; + let result = filter_bazel_query(stdout, "", 2, usize::MAX); + + assert!(result.contains("//src/app (1 target)")); + assert!(result.contains("//src/lib (1 target)")); + assert!(result.contains("🎯 :a")); + assert!(result.contains("🎯 :b")); + } + /******************************************************************/ /* bazel test tests */ /******************************************************************/ @@ -2336,10 +2654,13 @@ INFO: 0 processes. ERROR: Build did NOT complete successfully"; let result = brun("", stderr); - assert!(result.contains("bazel build: 2 errors, 1 warning")); + assert!( + result.contains("bazel build:") && result.contains("warning"), + "unexpected output:\n{}", + result + ); assert!(result.contains("ERROR: Skipping")); assert!(result.contains("ERROR: no such target")); - assert!(result.contains("WARNING: Target pattern parsing failed")); assert!(!result.contains("Build did NOT complete successfully")); // No binary output assert!(!result.contains("Running command line")); @@ -2495,7 +2816,6 @@ binary output on stderr"; assert!(!result.contains("Running command line")); } - #[test] #[test] fn test_filter_bazel_run_real_world_output() { // Realistic output from `bazel run` with timestamped lines, env-prefixed @@ -2532,30 +2852,57 @@ Target //src/tools/my_tool:my_tool up-to-date: // Binary output only assert!(result.contains("Processing input...")); assert!(result.contains("Done.")); - // All noise stripped + // Pre-sentinel build noise stripped assert!(!result.contains("Computing main repo")); assert!(!result.contains("Loading:")); assert!(!result.contains("Analyzing:")); assert!(!result.contains("[0 / 1]")); - assert!(!result.contains("INFO:")); + // Post-sentinel output is preserved verbatim + assert!(result.contains("INFO: Some trailing info line")); assert!(!result.contains("Running command line")); assert!(!result.contains("FOO=1")); } #[test] - fn test_filter_bazel_run_post_sentinel_info_stripped() { - // Verify that INFO lines after the sentinel are stripped, not forwarded + fn test_filter_bazel_run_post_sentinel_prefixed_lines_preserved() { + // INFO/WARNING/DEBUG after sentinel are binary stderr and must be preserved. let stderr = "\ INFO: Build completed successfully, 10 total actions INFO: Running command line: bazel-bin/app INFO: Some trailing info line +WARNING: App warning +DEBUG: App debug INFO: Another trailing info line actual binary error output"; let result = brun("binary stdout", stderr); assert!(result.contains("binary stdout")); assert!(result.contains("actual binary error output")); - assert!(!result.contains("Some trailing info")); - assert!(!result.contains("Another trailing info")); + assert!(result.contains("INFO: Some trailing info line")); + assert!(result.contains("WARNING: App warning")); + assert!(result.contains("DEBUG: App debug")); + assert!(result.contains("INFO: Another trailing info line")); + } + + #[test] + fn test_filter_bazel_run_preserves_trailing_newline() { + let stderr = "\ +INFO: Build completed successfully, 10 total actions +INFO: Running command line: bazel-bin/app"; + let result = brun("line1\nline2\n", stderr); + + assert!(result.ends_with('\n')); + assert!(result.contains("line1\nline2\n")); + } + + #[test] + fn test_filter_bazel_run_preserves_leading_whitespace() { + let stderr = "\ +INFO: Build completed successfully, 10 total actions +INFO: Running command line: bazel-bin/app"; + let result = brun(" indented\n\tTabbed\n", stderr); + + assert!(result.contains(" indented")); + assert!(result.contains("\tTabbed")); } } From 7dfc245579f70b7a8523eb00540103348611ca06 Mon Sep 17 00:00:00 2001 From: cmolder <28611108+cmolder@users.noreply.github.com> Date: Mon, 2 Mar 2026 23:27:49 -0800 Subject: [PATCH 6/9] Add bazel auto-rewrite support to Claude hook - rewrite bazel build/test/run/query/aquery/cquery to rtk bazel - add hook test coverage for bazel rewrite and env-prefixed cases - document bazel rewrite mapping in README Co-authored-by: Codex --- README.md | 1 + hooks/test-rtk-rewrite.sh | 16 ++++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/README.md b/README.md index 8158e1ce..9741dbe3 100644 --- a/README.md +++ b/README.md @@ -623,6 +623,7 @@ The hook is included in this repository at `.claude/hooks/rtk-rewrite.sh`. To us | `git status/diff/log/add/commit/push/pull/branch/fetch/stash` | `rtk git ...` | | `gh pr/issue/run` | `rtk gh ...` | | `cargo test/build/clippy` | `rtk cargo ...` | +| `bazel build/test/run/query` | `rtk bazel ...` | | `cat ` | `rtk read ` | | `rg/grep ` | `rtk grep ` | | `ls` | `rtk ls` | diff --git a/hooks/test-rtk-rewrite.sh b/hooks/test-rtk-rewrite.sh index 502023c0..950d4120 100755 --- a/hooks/test-rtk-rewrite.sh +++ b/hooks/test-rtk-rewrite.sh @@ -113,6 +113,14 @@ test_rewrite "cargo test" \ "cargo test" \ "rtk cargo test" +test_rewrite "bazel query //src/..." \ + "bazel query //src/..." \ + "rtk bazel query //src/..." + +test_rewrite "bazel build //src:bazel-dev" \ + "bazel build //src:bazel-dev" \ + "rtk bazel build //src:bazel-dev" + test_rewrite "npx prisma migrate" \ "npx prisma migrate" \ "rtk prisma migrate" @@ -145,6 +153,10 @@ test_rewrite "env + npm run" \ "NODE_ENV=test npm run test:e2e" \ "NODE_ENV=test rtk npm test:e2e" +test_rewrite "env + bazel query" \ + "USE_BAZEL_VERSION=8.2.0 bazel query //src/..." \ + "USE_BAZEL_VERSION=8.2.0 rtk bazel query //src/..." + test_rewrite "env + docker compose (unsupported subcommand, NOT rewritten)" \ "COMPOSE_PROJECT_NAME=test docker compose up -d" \ "" @@ -333,6 +345,10 @@ test_rewrite "node (no pattern)" \ "node -e 'console.log(1)'" \ "" +test_rewrite "bazel startup flags before subcommand (NOT rewritten)" \ + "bazel --output_base=/tmp/bazel query //src/..." \ + "" + echo "" # ---- SECTION 6: Audit logging ---- From 573e30309c1b6757c69852ecbd8176962ce05d00 Mon Sep 17 00:00:00 2001 From: cmolder <28611108+cmolder@users.noreply.github.com> Date: Wed, 4 Mar 2026 16:57:04 -0800 Subject: [PATCH 7/9] Refactor `bazel {build, test, run}` filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure all three bazel subcommand filters with cleaner architecture: - Extract `BazelBuildState` and `DiagnosticBlockState` structs to encapsulate build output parsing (errors, warnings, compiler diagnostics) - Extract `BazelTestState` struct for test result accumulation - Add `BazelRunStage` enum to formalize build/run phase separation - Unify filter signatures from stdout/stderr to single combined string, matching how `run_build`/`run_test` already concatenate them - Add `BAZEL_EXTRA_FLAGS` constant to suppress progress/timestamps/ loading noise at the bazel CLI level - `bazel test` filter now uses only native bazel result lines, with detailed output available via tee file - Remove `strip_timestamp()` and `TIMESTAMP_PREFIX` regex — timestamps are now suppressed via `--noshow_timestamps` flag instead - Remove `PROGRESS_LINE` regex, since progress is now suppressed via `--noshow_progress` flag - Rewrite and expand test suite with more granular coverage (compiler warnings per toolchain, whitespace preservation, etc.) Co-Authored-By: Claude Opus 4.6 (1M context) Co-Authored-By: Codex --- src/bazel_cmd.rs | 2624 ++++++++++++++++++++++++---------------------- 1 file changed, 1353 insertions(+), 1271 deletions(-) diff --git a/src/bazel_cmd.rs b/src/bazel_cmd.rs index fa1220f3..842ee725 100644 --- a/src/bazel_cmd.rs +++ b/src/bazel_cmd.rs @@ -13,22 +13,12 @@ use std::str::FromStr; /**********************************************************************/ lazy_static! { - /// Matches optional leading Bazel timestamp prefix: "(HH:MM:SS) " - static ref TIMESTAMP_PREFIX: Regex = - Regex::new(r"^\(\d+:\d+:\d+\)\s*").unwrap(); - /// Matches Bazel target lines /// /// e.g. "//package/path:target_name", "//:root_target", "@repo//pkg:target" static ref TARGET_LINE: Regex = Regex::new(r"^((?:@[^/\s:]+)?//[^:]*):(.+)$").unwrap(); - /// Matches Bazel progress lines - /// - /// e.g. "[123 / 4,567] Progress message..." - static ref PROGRESS_LINE: Regex = - Regex::new(r"^\[[\d,]+ / [\d,]+\]").unwrap(); - /// Matches INFO lines with action counts /// /// e.g. "123 total actions", "1 total action" @@ -74,21 +64,19 @@ lazy_static! { /// Matches the "Running command line:" sentinel that separates build from execution /// /// e.g. "INFO: Running command line: bazel-bin/path/to/binary" - /// Note: timestamp prefix is already stripped by strip_timestamp() before matching static ref RUN_SENTINEL: Regex = Regex::new(r"^INFO: Running command line:").unwrap(); } -/// Strip optional leading Bazel timestamp prefix "(HH:MM:SS) " from a line. -/// -/// Bazel may prepend timestamps to all output lines (e.g. `(17:17:06) Loading:`). -/// This normalizes them so `starts_with` checks work regardless of timestamp presence. -fn strip_timestamp(line: &str) -> &str { - TIMESTAMP_PREFIX - .find(line) - .map(|m| &line[m.end()..]) - .unwrap_or(line) -} +/// Extra flags to pass to Bazel commands. +const BAZEL_EXTRA_FLAGS: [&str; 3] = [ + "--noshow_progress", + "--noshow_timestamps", + "--noshow_loading_progress", +]; + +/// Maximum number of build issues (errors, warnings) to show. +const MAX_BUILD_ISSUES: usize = 15; /// A limit value that can be a specific number or unlimited ("all"). #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -136,218 +124,281 @@ impl fmt::Display for Limit { /* bazel build */ /**********************************************************************/ -/// Filter `bazel build` output. -/// -/// # Arguments -/// -/// * `stdout` - stdout output from `bazel build` -/// * `stderr` - stderr output from `bazel build` -/// -/// # Returns -/// -/// The filtered `bazel build` output -/// -/// # Notes -/// -/// Strips progress and info noise, while keeping errors and warnings. -/// Bazel sends most output to stderr. This function reads the combined -/// stdout and stderr stream and filters the following: -/// * Progress lines `[N / M]` -/// * Loading/Analyzing -/// * INFO -/// * Note -/// * Target/bazel-bin output paths -/// -/// Meanwhile, the following lines are kept: -/// * ERROR lines -/// * WARNING lines -/// * Build diagnostics (e.g. warnings/errors from gcc/clang) -/// -pub fn filter_bazel_build(stdout: &str, stderr: &str) -> String { - let mut errors: Vec = Vec::new(); - let mut warnings: Vec = Vec::new(); - let mut error_count: usize = 0; - let mut warning_count: usize = 0; - let mut action_count: Option = None; - - // State for collecting multi-line compiler diagnostic blocks - let mut in_diagnostic = false; - let mut current_block: Vec = Vec::new(); - let mut current_is_error = false; - - // Combine stdout and stderr (bazel sends most to stderr) - let combined = format!("{}\n{}", stderr, stdout); - - for line in combined.lines() { - let trimmed = line.trim(); - // Strip optional "(HH:MM:SS) " timestamp prefix so starts_with checks work - let stripped = strip_timestamp(trimmed); - if stripped.is_empty() { - // Blank line ends a diagnostic block - if in_diagnostic && !current_block.is_empty() { - if current_is_error { - errors.push(current_block.join("\n")); - } else { - warnings.push(current_block.join("\n")); - } - current_block.clear(); - in_diagnostic = false; - } - continue; - } +/// Tracks state when filtering compiler diagnostic blocks. +#[derive(Debug, Default)] +struct DiagnosticBlockState { + /// Lines of the diagnostic block + pub block: Vec, - // Extract action count from INFO lines before skipping them - if stripped.starts_with("INFO:") || stripped.starts_with("DEBUG:") { - if let Some(caps) = ACTION_COUNT.captures(stripped) { - action_count = Some(caps[1].to_string()); - } - // "INFO: From ..." lines precede compiler output — skip the INFO line itself - // but don't skip the following compiler diagnostic lines - continue; - } + /// Whether the diagnostic block is an error + pub is_error: bool, +} - // Strip progress lines: [N / M] ... - if PROGRESS_LINE.is_match(stripped) { - continue; - } +impl DiagnosticBlockState { + fn message(&self) -> String { + self.block.join("\n") + } - // Strip loading/analyzing status - if stripped.starts_with("Loading:") - || stripped.starts_with("Analyzing:") - || stripped.starts_with("Computing main repo mapping:") - { - continue; - } + const fn is_error(&self) -> bool { + self.is_error + } - // Strip Java notes - if stripped.starts_with("Note: ") { - continue; - } + fn consume(&mut self) -> String { + let message = self.message(); + self.is_error = false; + self.block.clear(); + message + } +} - // Strip target output paths - if stripped.starts_with("Target //") || stripped.starts_with("bazel-bin/") { - continue; +/// Tracks state when filtering `bazel build` output. +#[derive(Debug, Default)] +struct BazelBuildState { + /// Errors + errors: Vec, + + /// Warnings + warnings: Vec, + + /// Current diagnostic block being processed + diagnostic: DiagnosticBlockState, + + /// Number of actions in the build + action_count: Option, +} + +impl BazelBuildState { + const fn num_errors(&self) -> usize { + self.errors.len() + } + + const fn num_warnings(&self) -> usize { + self.warnings.len() + } + + const fn success(&self) -> bool { + self.num_errors() == 0 && self.num_warnings() == 0 + } + + const fn has_errors(&self) -> bool { + self.num_errors() > 0 + } + + const fn has_warnings(&self) -> bool { + self.num_warnings() > 0 + } + + const fn in_diagnostic_block(&self) -> bool { + !self.diagnostic.block.is_empty() + } + + const fn action_count(&self) -> Option { + self.action_count + } + + fn errors(&self) -> &[String] { + &self.errors + } + + fn warnings(&self) -> &[String] { + &self.warnings + } + + fn consume_diagnostic(&mut self) { + let is_error = self.diagnostic.is_error(); + let msg = self.diagnostic.consume(); + if !msg.is_empty() { + if is_error { + self.errors.push(msg); + } else { + self.warnings.push(msg); + } } + } - // Strip DEBUG lines - if stripped.starts_with("DEBUG:") { - continue; + fn digest_line(&mut self, line: &str) { + let trimmed = line.trim(); + + // Bazel action count + if let Some(ac) = ACTION_COUNT.captures(trimmed) { + assert!(self.action_count.is_none(), "action count already set"); + let ac = ac[1].parse::().expect("expected a number"); + self.action_count = Some(ac); } - // Bazel-level ERROR lines - if stripped.starts_with("ERROR:") { - // Flush any in-progress diagnostic block - if in_diagnostic && !current_block.is_empty() { - if current_is_error { - errors.push(current_block.join("\n")); - } else { - warnings.push(current_block.join("\n")); - } - current_block.clear(); - in_diagnostic = false; + // Bazel error + if trimmed.starts_with("ERROR:") { + // Flush any in-progress compiler diagnostic block. + if self.in_diagnostic_block() { + self.consume_diagnostic(); } + // Skip the summary "Build did NOT complete successfully" — we show our own header - if stripped.contains("Build did NOT complete successfully") { - error_count = error_count.max(1); // ensure we show error header - continue; + if trimmed.contains("Build did NOT complete successfully") { + return; + // error_count = error_count.max(1); // Ensure we show error header } - error_count += 1; - errors.push(stripped.to_string()); - continue; + + // Add the Bazel error + self.errors.push(trimmed.to_string()); + return; } - // Bazel-level WARNING lines (already caught above in INFO/WARNING/DEBUG gate, - // but standalone WARNING lines without prior INFO context reach here) - if stripped.starts_with("WARNING:") { - // Flush any in-progress diagnostic block - if in_diagnostic && !current_block.is_empty() { - if current_is_error { - errors.push(current_block.join("\n")); - } else { - warnings.push(current_block.join("\n")); - } - current_block.clear(); - in_diagnostic = false; + // Bazel warning + if trimmed.starts_with("WARNING:") { + // Flush any in-progress compiler diagnostic block. + if self.in_diagnostic_block() { + self.consume_diagnostic(); } - warning_count += 1; - warnings.push(stripped.to_string()); - continue; + + // Add the Bazel warning + self.warnings.push(trimmed.to_string()); + return; } - // Compiler diagnostic: "file:line:col: warning: ..." or "file:line:col: error: ..." - // These come from gcc/clang output embedded in bazel stderr - if trimmed.contains(": warning:") || trimmed.contains(": error:") { - // Flush previous block if any - if in_diagnostic && !current_block.is_empty() { - if current_is_error { - errors.push(current_block.join("\n")); - } else { - warnings.push(current_block.join("\n")); - } - current_block.clear(); - } - current_is_error = trimmed.contains(": error:"); - if current_is_error { - error_count += 1; - } else { - warning_count += 1; + // Start of diagnostic block + if trimmed.starts_with("warning:") + || trimmed.starts_with("error:") + || trimmed.contains(": warning:") + || trimmed.contains(": error:") + { + // Flush previous diagnostic block, if any. + if self.in_diagnostic_block() { + self.consume_diagnostic(); } - in_diagnostic = true; - current_block.push(trimmed.to_string()); - continue; + + // Add the diagnostic block + self.diagnostic.block.push(trimmed.to_string()); + self.diagnostic.is_error = trimmed.contains(": error:"); + return; } - // Continuation of a compiler diagnostic block (source context, notes, etc.) - if in_diagnostic { - // Lines with ` | `, `note:`, source locations, or caret lines are context - current_block.push(trimmed.to_string()); - continue; + // Currently inside diagnostic block + if self.in_diagnostic_block() { + if trimmed.is_empty() { + // End of diagnostic block + self.consume_diagnostic(); + } else { + // Inside diagnostic block + self.diagnostic.block.push(trimmed.to_string()); + } } - // Anything else that doesn't match known noise — skip - // (indented bazel-bin paths, etc.) + // Everything else is ignored. } - // Flush final block - if in_diagnostic && !current_block.is_empty() { - if current_is_error { - errors.push(current_block.join("\n")); - } else { - warnings.push(current_block.join("\n")); + fn finalize(&mut self) { + // Flush any in-progress compiler diagnostic block. + if self.in_diagnostic_block() { + self.consume_diagnostic(); } } +} + +/// Filter `bazel build` output. +/// +/// # Arguments +/// +/// * `output` - Output from `bazel build` +/// +/// # Returns +/// +/// Filtered `bazel build` output +/// +/// # Notes +/// +/// This function detects errors, warnings, and build results and +/// condenses them. +/// * Error lines (e.g. `ERROR: ...`) +/// * Warning lines (e.g. `WARNING: ...`) +/// * Compiler diagnostics (e.g. from gcc/clang/rustc) +/// +/// [`BAZEL_EXTRA_FLAGS`] already filters out a lot of build noise. +/// This function assumes these things have already been filtered out: +/// * Progress lines (e.g. `[100 / 200] 5 actions, 4 running`) +/// * Status lines (e.g. `Loading ...`) +/// * Timestamps +/// +pub fn filter_bazel_build(output: &str) -> String { + let mut state = BazelBuildState::default(); + for line in output.lines() { + state.digest_line(line); + } + state.finalize(); + + // Build the summary line. + // + // Examples: + // "✓ bazel build (1337 actions)" + // "bazel build: 1 warning (1337 actions)" + // "bazel build: 2 errors (1337 actions)" + // "bazel build: 1 error, 4 warnings (1337 actions)" + let build_summary = { + let mut build_summary: String = String::new(); + + // Success checkmark + if state.success() { + build_summary.push_str("✓ "); + } + + // Command name + build_summary.push_str("bazel build"); + + // Errors + if state.has_errors() { + let suffix = if state.num_errors() == 1 { "" } else { "s" }; + build_summary.push_str(&format!(": {} error{}", state.num_errors(), suffix)); + } + + // Warnings + if state.has_warnings() { + let prefix = if state.has_errors() { ", " } else { ": " }; + let suffix = if state.num_warnings() == 1 { "" } else { "s" }; + build_summary.push_str(&format!( + "{}{} warning{}", + prefix, + state.num_warnings(), + suffix + )); + } - let actions_str = action_count.unwrap_or_else(|| "0".to_string()); + // Actions + if let Some(action_count) = state.action_count { + let suffix = if action_count == 1 { "" } else { "s" }; + build_summary.push_str(&format!(" ({} action{})", action_count, suffix)); + } + + build_summary + }; - // No errors or warnings: one-liner success - if error_count == 0 && warning_count == 0 { - return format!("✓ bazel build ({} actions)", actions_str); + // If succesful, return only the summary line. + if state.success() { + return build_summary; } - // Format with header + blocks - let mut result = String::new(); - result.push_str(&format!( - "bazel build: {} error{}, {} warning{} ({} actions)\n", - error_count, - if error_count == 1 { "" } else { "s" }, - warning_count, - if warning_count == 1 { "" } else { "s" }, - actions_str, - )); - result.push_str("═══════════════════════════════════════\n"); + // Otherwise, include a summary of the warnings and errors. + let mut result = build_summary; + result.push_str("\n═══════════════════════════════════════\n"); // Show errors first, then warnings - let all_blocks: Vec<&String> = errors.iter().chain(warnings.iter()).collect(); - for (i, block) in all_blocks.iter().enumerate().take(15) { + let all_blocks: Vec<&String> = state + .errors() + .iter() + .chain(state.warnings().iter()) + .collect(); + for (i, block) in all_blocks.iter().enumerate().take(MAX_BUILD_ISSUES) { result.push_str(block); result.push('\n'); - if i < all_blocks.len().min(15) - 1 { + if i < all_blocks.len().min(MAX_BUILD_ISSUES) - 1 { result.push('\n'); } } - if all_blocks.len() > 15 { - result.push_str(&format!("\n... +{} more issues\n", all_blocks.len() - 15)); + if all_blocks.len() > MAX_BUILD_ISSUES { + result.push_str(&format!( + "\n... +{} more issues\n", + all_blocks.len() - MAX_BUILD_ISSUES + )); } result.trim().to_string() @@ -369,6 +420,7 @@ pub fn run_build(args: &[String], verbose: u8) -> Result<()> { let mut cmd = Command::new("bazel"); cmd.arg("build"); + cmd.args(BAZEL_EXTRA_FLAGS); for arg in args { cmd.arg(arg); @@ -390,7 +442,7 @@ pub fn run_build(args: &[String], verbose: u8) -> Result<()> { .status .code() .unwrap_or(if output.status.success() { 0 } else { 1 }); - let filtered = filter_bazel_build(&stdout, &stderr); + let filtered = filter_bazel_build(&raw); if let Some(hint) = crate::tee::tee_and_hint(&raw, "bazel_build", exit_code) { println!("{}\n{}", filtered, hint); @@ -416,200 +468,211 @@ pub fn run_build(args: &[String], verbose: u8) -> Result<()> { /* bazel test */ /**********************************************************************/ -/// Filter `bazel test` output. -/// -/// # Arguments -/// -/// * `stdout` - stdout output from `bazel test` -/// * `stderr` - stderr output from `bazel test` -/// -/// # Returns -/// -/// The filtered `bazel test` output -/// -/// # Notes -/// -/// Strips the same build noise as `filter_bazel_build`, plus parses test -/// result lines (PASSED/FAILED/TIMEOUT) and inline test output blocks. -/// On all-pass, returns a one-liner. On failure, shows FAIL blocks and -/// inline test output while stripping surrounding noise. -/// -pub fn filter_bazel_test(stdout: &str, stderr: &str) -> String { - let mut passed: usize = 0; - let mut failed: usize = 0; - let mut elapsed: Option = None; - let mut error_lines: Vec = Vec::new(); - let mut fail_blocks: Vec = Vec::new(); - let mut failed_result_lines: Vec = Vec::new(); - let mut inline_output_blocks: Vec = Vec::new(); - - // State for collecting inline test output - let mut in_test_output = false; - let mut current_output_block: Vec = Vec::new(); - - // Combine stderr + stdout (bazel sends most to stderr) - let combined = format!("{}\n{}", stderr, stdout); - - for line in combined.lines() { +#[derive(Debug, Default)] +struct BazelTestState { + passed: usize, + failed: usize, + elapsed: Option, + + error_lines: Vec, + fail_lines: Vec, + failed_result_lines: Vec, + inline_output_blocks: Vec, + + in_test_output: bool, + current_output_block: Vec, +} + +impl BazelTestState { + fn digest_line(&mut self, raw_line: &str) { + let line = raw_line.trim_end_matches('\r'); let trimmed = line.trim(); - let stripped = strip_timestamp(trimmed); + let stripped = trimmed; - // Collecting inline test output between delimiter lines - if in_test_output { + // Collecting inline test output between delimiter lines. + if self.in_test_output { if TEST_OUTPUT_END.is_match(stripped) { - current_output_block.push(stripped.to_string()); - inline_output_blocks.push(current_output_block.join("\n")); - current_output_block.clear(); - in_test_output = false; + self.current_output_block.push(stripped.to_string()); + self.inline_output_blocks + .push(self.current_output_block.join("\n")); + self.current_output_block.clear(); + self.in_test_output = false; } else { - current_output_block.push(line.to_string()); + self.current_output_block.push(line.to_string()); } - continue; + return; } if stripped.is_empty() { - continue; + return; } - // Extract elapsed time before skipping INFO/DEBUG lines + // Extract elapsed time before skipping INFO/DEBUG lines. if stripped.starts_with("INFO:") || stripped.starts_with("DEBUG:") { if let Some(caps) = ELAPSED_TIME.captures(stripped) { - elapsed = Some(caps[1].to_string()); + self.elapsed = Some(caps[1].to_string()); } - continue; - } - - // Strip progress lines: [N / M] ... - if PROGRESS_LINE.is_match(stripped) { - continue; + return; } - // Strip loading/analyzing status + // Strip loading/analyzing status. if stripped.starts_with("Loading:") || stripped.starts_with("Analyzing:") || stripped.starts_with("Computing main repo mapping:") { - continue; + return; } - // Strip Java notes + // Strip Java notes. if stripped.starts_with("Note: ") { - continue; + return; } - // Strip target output paths + // Strip target output paths. if stripped.starts_with("Target //") || stripped.starts_with("bazel-bin/") { - continue; + return; } - // Strip DEBUG lines + // Strip DEBUG lines. if stripped.starts_with("DEBUG:") { - continue; + return; } - // Strip timeout size warnings + // Strip timeout size warnings. if stripped.starts_with("There were tests whose specified size") { - continue; + return; } - // Test result lines: //pkg:test PASSED in 0.3s + // Test result lines: //pkg:test PASSED in 0.3s. if let Some(caps) = TEST_RESULT_LINE.captures(stripped) { let status = &caps[2]; match status { - "PASSED" => passed += 1, + "PASSED" => self.passed += 1, "FAILED" | "TIMEOUT" | "NO STATUS" => { - failed += 1; - failed_result_lines.push(stripped.to_string()); + self.failed += 1; + self.failed_result_lines.push(stripped.to_string()); } - "FLAKY" => passed += 1, // flaky but passed on retry + "FLAKY" => self.passed += 1, // flaky but passed on retry _ => {} } - continue; + return; } - // Executed summary line (skip — we produce our own) + // Executed summary line (skip — we produce our own). if TEST_SUMMARY.is_match(stripped) { - continue; + return; } - // FAIL: lines + // FAIL: lines. if FAIL_LINE.is_match(stripped) { - fail_blocks.push(stripped.to_string()); - continue; + self.fail_lines.push(stripped.to_string()); + return; } - // Inline test output start + // Inline test output start. if TEST_OUTPUT_START.is_match(stripped) { - in_test_output = true; - current_output_block.push(stripped.to_string()); - continue; + self.in_test_output = true; + self.current_output_block.push(stripped.to_string()); + return; } - // ERROR lines + // ERROR lines. if stripped.starts_with("ERROR:") { if stripped.contains("Build did NOT complete successfully") || stripped.contains("not all tests passed") { - continue; + return; } - error_lines.push(stripped.to_string()); - continue; + self.error_lines.push(stripped.to_string()); + return; } - // WARNING lines (strip — build noise) + // WARNING lines (strip — build noise). if stripped.starts_with("WARNING:") { - continue; + return; } - // Indented log paths after FAILED lines (e.g. " /path/to/test.log") - // Keep only if we have failures - if stripped.starts_with('/') && stripped.ends_with(".log") && failed > 0 { - continue; // skip log paths — we show inline output instead + // Indented log paths after FAILED lines (e.g. " /path/to/test.log"). + // Keep only if we have failures. + if stripped.starts_with('/') && stripped.ends_with(".log") && self.failed > 0 { + return; // skip log paths — we show inline output instead } - // Everything else is noise — skip + // Everything else is noise — skip. } - // Flush any unclosed test output block - if !current_output_block.is_empty() { - inline_output_blocks.push(current_output_block.join("\n")); + fn finalize(&mut self) { + if !self.current_output_block.is_empty() { + self.inline_output_blocks + .push(self.current_output_block.join("\n")); + self.current_output_block.clear(); + self.in_test_output = false; + } + } +} + +/// Filter `bazel test` output. +/// +/// # Arguments +/// +/// * `output` - output from `bazel test` +/// +/// # Returns +/// +/// The filtered `bazel test` output +/// +/// # Notes +/// +/// Strips the same build noise as `filter_bazel_build`, plus parses test +/// result lines (PASSED/FAILED/TIMEOUT) and inline test output blocks. +/// On all-pass, returns a one-liner. On failure, shows FAIL blocks and +/// inline test output while stripping surrounding noise. +/// +pub fn filter_bazel_test(output: &str) -> String { + let mut state = BazelTestState::default(); + for line in output.lines() { + state.digest_line(line); } + state.finalize(); - let elapsed_str = elapsed.unwrap_or_else(|| "0".to_string()); + let elapsed_str = state.elapsed.unwrap_or_else(|| "0".to_string()); - // Build error — no test results but ERROR lines present - if passed == 0 && failed == 0 && !error_lines.is_empty() { + // Build error — no test results but ERROR lines present. + if state.passed == 0 && state.failed == 0 && !state.error_lines.is_empty() { let mut result = String::from("bazel test: build failed\n"); result.push_str("═══════════════════════════════════════\n"); - for err in error_lines.iter().take(15) { + for err in state.error_lines.iter().take(15) { result.push_str(err); result.push('\n'); } - if error_lines.len() > 15 { - result.push_str(&format!("\n... +{} more errors\n", error_lines.len() - 15)); + if state.error_lines.len() > 15 { + result.push_str(&format!( + "\n... +{} more errors\n", + state.error_lines.len() - 15 + )); } return result.trim().to_string(); } - // All pass: one-liner - if failed == 0 { + // All pass: one-liner. + if state.failed == 0 { return format!( "\u{2713} bazel test: {} passed, 0 failed ({}s)", - passed, elapsed_str + state.passed, elapsed_str ); } - // Has failures: show details + // Has failures: show details. let mut result = String::new(); result.push_str(&format!( "bazel test: {} failed, {} passed ({}s)\n", - failed, passed, elapsed_str + state.failed, state.passed, elapsed_str )); result.push_str("═══════════════════════════════════════\n"); - // FAIL: lines + // FAIL: lines. let mut block_count = 0; - for fail in &fail_blocks { + for fail in &state.fail_lines { if block_count >= 15 { break; } @@ -618,8 +681,8 @@ pub fn filter_bazel_test(stdout: &str, stderr: &str) -> String { block_count += 1; } - // Inline test output blocks - for block in &inline_output_blocks { + // Inline test output blocks. + for block in &state.inline_output_blocks { if block_count >= 15 { break; } @@ -631,8 +694,8 @@ pub fn filter_bazel_test(stdout: &str, stderr: &str) -> String { block_count += 1; } - // FAILED result lines - for line in &failed_result_lines { + // FAILED result lines. + for line in &state.failed_result_lines { if block_count >= 15 { break; } @@ -644,8 +707,8 @@ pub fn filter_bazel_test(stdout: &str, stderr: &str) -> String { block_count += 1; } - // Error lines (if any) - for err in &error_lines { + // Error lines (if any). + for err in &state.error_lines { if block_count >= 15 { break; } @@ -654,10 +717,10 @@ pub fn filter_bazel_test(stdout: &str, stderr: &str) -> String { block_count += 1; } - let total_blocks = fail_blocks.len() - + inline_output_blocks.len() - + failed_result_lines.len() - + error_lines.len(); + let total_blocks = state.fail_lines.len() + + state.inline_output_blocks.len() + + state.failed_result_lines.len() + + state.error_lines.len(); if total_blocks > 15 { result.push_str(&format!("\n... +{} more blocks\n", total_blocks - 15)); } @@ -702,7 +765,7 @@ pub fn run_test(args: &[String], verbose: u8) -> Result<()> { .status .code() .unwrap_or(if output.status.success() { 0 } else { 1 }); - let filtered = filter_bazel_test(&stdout, &stderr); + let filtered = filter_bazel_test(&raw); if let Some(hint) = crate::tee::tee_and_hint(&raw, "bazel_test", exit_code) { println!("{}\n{}", filtered, hint); @@ -728,12 +791,18 @@ pub fn run_test(args: &[String], verbose: u8) -> Result<()> { /* bazel run */ /**********************************************************************/ +/// Current stage of the `bazel run` process +#[derive(Debug, PartialEq)] +enum BazelRunStage { + Build, + Run, +} + /// Filter `bazel run` output. /// /// # Arguments /// -/// * `stdout` - stdout output from `bazel run` (binary's stdout) -/// * `stderr` - stderr output from `bazel run` (build noise + binary's stderr) +/// * `output` - output from `bazel run` /// /// # Returns /// @@ -750,70 +819,109 @@ pub fn run_test(args: &[String], verbose: u8) -> Result<()> { /// This filter splits stderr at the sentinel, applies `filter_bazel_build` /// to the build phase, then appends the binary's output verbatim. /// -pub fn filter_bazel_run(stdout: &str, stderr: &str, args: &[String]) -> String { - // Split stderr at the sentinel line, collecting warnings separately - let mut build_stderr = String::new(); - let mut build_warnings: Vec = Vec::new(); - let mut binary_stderr = String::new(); - let mut found_sentinel = false; - let mut has_errors = false; - - for segment in stderr.split_inclusive('\n') { - let line = segment.strip_suffix('\n').unwrap_or(segment); - let stripped = strip_timestamp(line.trim()); - if !found_sentinel { - if RUN_SENTINEL.is_match(stripped) { - found_sentinel = true; - continue; - } - // Collect warnings separately — only include if build has errors - if stripped.starts_with("WARNING:") { - build_warnings.push(line.to_string()); - continue; +pub fn filter_bazel_run(output: &str) -> String { + let mut current_stage = BazelRunStage::Build; + let mut build_state = BazelBuildState::default(); + let mut run_lines: Vec = Vec::new(); + + for line in output.split_inclusive('\n') { + let trimmed = line.trim(); + match current_stage { + // Build stage + BazelRunStage::Build => { + if RUN_SENTINEL.is_match(trimmed) { + // Move to execution stage and output all stdout + // and stderr verbatim + build_state.finalize(); + current_stage = BazelRunStage::Run; + } else { + build_state.digest_line(trimmed); + } } - if stripped.starts_with("ERROR:") { - has_errors = true; + // Run stage + BazelRunStage::Run => { + // Collect run lines verbatim + run_lines.push(line.to_string()); } - build_stderr.push_str(segment); - } else { - // Post-sentinel stderr belongs to the executed binary; preserve it verbatim. - binary_stderr.push_str(segment); } } - // Re-inject warnings if build had errors (they provide context) - if has_errors { - for w in &build_warnings { - build_stderr.push_str(w); - build_stderr.push('\n'); + // If no errors, just return the run lines verbatim + // + // Warnings are ignored unless errors also occured + if !build_state.has_errors() { + // `split_inclusive('\n')` preserves newline delimiters in each segment, + // so concatenate directly to avoid injecting extra blank lines. + return run_lines.concat(); + } + + // Build the summary line. + // + // Examples: + // "bazel run: 2 errors" + // "bazel run: 1 error, 4 warnings " + let build_summary = { + let mut build_summary: String = String::new(); + + // Command name + build_summary.push_str("bazel run"); + + // Errors + let error_num = build_state.num_errors(); + let error_suffix = if error_num == 1 { "" } else { "s" }; + build_summary.push_str(&format!(": {} error{}", error_num, error_suffix)); + + // Warnings + if build_state.has_warnings() { + let prefix = if error_num > 0 { ", " } else { ": " }; + let suffix = if build_state.num_warnings() == 1 { + "" + } else { + "s" + }; + build_summary.push_str(&format!( + "{}{} warning{}", + prefix, + build_state.num_warnings(), + suffix + )); } - } - // Filter the build phase using existing filter_bazel_build - let build_summary = filter_bazel_build("", &build_stderr); + // Actions + if let Some(action_count) = build_state.action_count() { + let suffix = if action_count == 1 { "" } else { "s" }; + build_summary.push_str(&format!(" ({} action{})", action_count, suffix)); + } - // Combine binary output exactly as captured from stdout + post-sentinel stderr. - let mut binary_output = String::new(); - binary_output.push_str(stdout); - binary_output.push_str(&binary_stderr); + build_summary + }; - // Format output based on build result - let build_clean = build_summary.starts_with('\u{2713}'); + // Include a summary of the warnings and errors. + let mut result = build_summary; + result.push_str("\n═══════════════════════════════════════\n"); - if binary_output.is_empty() { - // No binary output — show build summary only - build_summary - } else if build_clean { - // Clean build — skip build summary, just show binary output - binary_output - } else { - // Build had warnings/errors — show both sections - let run_header = format!( - "\n\nbazel run {}\n═══════════════════════════════════════", - args.join(" ") - ); - format!("{}{}\n{}", build_summary, run_header, binary_output) + // Show errors first, then warnings + let all_blocks: Vec<&String> = build_state + .errors() + .iter() + .chain(build_state.warnings().iter()) + .collect(); + for (i, block) in all_blocks.iter().enumerate().take(MAX_BUILD_ISSUES) { + result.push_str(block); + result.push('\n'); + if i < all_blocks.len().min(MAX_BUILD_ISSUES) - 1 { + result.push('\n'); + } } + + if all_blocks.len() > MAX_BUILD_ISSUES { + result.push_str(&format!( + "\n... +{} more issues\n", + all_blocks.len() - MAX_BUILD_ISSUES + )); + } + + result.trim().to_string() } /// Run `bazel run` while filtering the build output. @@ -832,6 +940,7 @@ pub fn run_run(args: &[String], verbose: u8) -> Result<()> { let mut cmd = Command::new("bazel"); cmd.arg("run"); + cmd.args(BAZEL_EXTRA_FLAGS); for arg in args { cmd.arg(arg); @@ -853,7 +962,7 @@ pub fn run_run(args: &[String], verbose: u8) -> Result<()> { .status .code() .unwrap_or(if output.status.success() { 0 } else { 1 }); - let filtered = filter_bazel_run(&stdout, &stderr, args); + let filtered = filter_bazel_run(&raw); if let Some(hint) = crate::tee::tee_and_hint(&raw, "bazel_run", exit_code) { println!("{}\n{}", filtered, hint); @@ -1277,13 +1386,13 @@ pub fn filter_bazel_query(stdout: &str, stderr: &str, depth: usize, width: usize // Collect ERROR lines from stderr for line in stderr.lines() { - let stripped = strip_timestamp(line.trim()); - if stripped.is_empty() { + let trimmed = line.trim(); + if trimmed.is_empty() { continue; } - if stripped.starts_with("ERROR:") { + if trimmed.starts_with("ERROR:") { has_error_lines = true; - result.push_str(stripped); + result.push_str(trimmed); result.push('\n'); } } @@ -1575,56 +1684,68 @@ mod tests { use super::*; /******************************************************************/ - /* Shared Bazel utilities tests */ + /* Test Helper Functions */ /******************************************************************/ - #[test] - fn test_limit_from_str() { - assert_eq!("1".parse::().unwrap(), Limit::N(1)); - assert_eq!("10".parse::().unwrap(), Limit::N(10)); - assert_eq!("0".parse::().unwrap(), Limit::N(0)); - assert_eq!("all".parse::().unwrap(), Limit::All); - assert_eq!("ALL".parse::().unwrap(), Limit::All); - assert_eq!("All".parse::().unwrap(), Limit::All); - assert!("invalid".parse::().is_err()); - assert!("".parse::().is_err()); + + fn count_tokens(text: &str) -> usize { + text.split_whitespace().count() } /******************************************************************/ /* bazel build tests */ /******************************************************************/ - fn build(stdout: &str, stderr: &str) -> String { - filter_bazel_build(stdout, stderr) - } - - fn count_tokens(text: &str) -> usize { - text.split_whitespace().count() - } + mod build { + use super::*; + + #[test] + /// Test `bazel build` filtering on a succesful build with one action. + fn test_filter_success_one_action() { + let output = "\ +INFO: Analyzed target //src:bazel-dev (0 packages loaded, 0 targets configured). +INFO: Found 1 target... +Target //src:bazel-dev up-to-date: +bazel-bin/src/bazel-dev +INFO: Elapsed time: 0.453s, Critical Path: 0.00s +INFO: 1 process: 1 internal. +INFO: Build completed successfully, 1 total action + "; + let result = filter_bazel_build(output); + assert_eq!(result, "✓ bazel build (1 action)"); + } - #[test] - fn test_filter_bazel_build_success() { - let stderr = "\ -Computing main repo mapping: -Loading: -Loading: 1 packages loaded -Analyzing: target //src:bazel-dev (6 packages loaded, 6 targets configured) + #[test] + /// Test `bazel build` filtering on a succesful build with multiple actions. + fn test_filter_success_multiple_actions() { + let output = "\ INFO: Analyzed target //src:bazel-dev (563 packages loaded, 24852 targets configured, 175 aspect applications). -[1 / 1] no actions running -[889 / 4,978] Compiling absl/numeric/int128.cc; 0s processwrapper-sandbox ... (256 actions, 255 running) -[1,084 / 4,978] Compiling absl/time/internal/cctz/src/time_zone_info.cc; 1s processwrapper-sandbox ... (256 actions, 255 running) -[4,976 / 4,978] Executing genrule //src:package-zip_jdk_allmodules; 1s processwrapper-sandbox INFO: Found 1 target... Target //src:bazel-dev up-to-date: - bazel-bin/src/bazel-dev +bazel-bin/src/bazel-dev INFO: Elapsed time: 54.859s, Critical Path: 49.98s INFO: 2391 processes: 3 internal, 1537 processwrapper-sandbox, 881 worker. INFO: Build completed successfully, 2391 total actions"; - let result = build("", stderr); - assert_eq!(result, "✓ bazel build (2391 actions)"); - } + let result = filter_bazel_build(output); + assert_eq!(result, "✓ bazel build (2391 actions)"); + } + + #[test] + /// Test `bazel build` filtering on a succesful build with no actions. + fn test_filter_success_no_actions() { + let output = "\ +INFO: Analyzed target //src:bazel-dev (563 packages loaded, 24852 targets configured, 175 aspect applications). +INFO: Found 1 target... +Target //src:bazel-dev up-to-date: +bazel-bin/src/bazel-dev +INFO: Elapsed time: 54.859s, Critical Path: 49.98s + "; + let result = filter_bazel_build(output); + assert_eq!(result, "✓ bazel build"); + } - #[test] - fn test_filter_bazel_build_with_warnings() { - let stderr = "\ + #[test] + /// Test `bazel build` filtering on a build with warnings. + fn test_filter_with_warnings() { + let output = "\ Computing main repo mapping: Loading: Loading: 1 packages loaded @@ -1633,101 +1754,172 @@ WARNING: /home/user/bazel/src/conditions/BUILD:119:15: select() on cpu is deprec WARNING: /home/user/bazel/src/conditions/BUILD:202:15: select() on cpu is deprecated. WARNING: /home/user/bazel/src/conditions/BUILD:193:15: select() on cpu is deprecated. INFO: Analyzed target //src:bazel-dev (563 packages loaded). -[889 / 4,978] Compiling absl/numeric/int128.cc; 0s processwrapper-sandbox -[4,976 / 4,978] Executing genrule //src:package-zip_jdk_allmodules; 1s INFO: Found 1 target... Target //src:bazel-dev up-to-date: - bazel-bin/src/bazel-dev +bazel-bin/src/bazel-dev INFO: Elapsed time: 54.859s, Critical Path: 49.98s INFO: Build completed successfully, 4978 total actions"; - let result = build("", stderr); - - assert!(result.contains("bazel build: 0 errors, 3 warnings (4978 actions)")); - assert!(result.contains("═══════════════════════════════════════")); - assert!(result.contains("WARNING:")); - assert!(result.contains("select() on cpu is deprecated")); - // Noise should be stripped - assert!(!result.contains("Loading:")); - assert!(!result.contains("Analyzing:")); - assert!(!result.contains("[889 / 4,978]")); - assert!(!result.contains("INFO:")); - } - - #[test] - fn test_filter_bazel_build_errors() { - let stderr = "\ + let result = filter_bazel_build(output); + + eprintln!("[DEBUG] Result is:\n{}", result); + + assert!(result.contains("bazel build: 3 warnings (4978 actions)")); + assert!(result.contains("═══════════════════════════════════════")); + assert!(result.contains("WARNING:")); + assert!(result.contains("select() on cpu is deprecated")); + // Noise should be stripped + assert!(!result.contains("Loading:")); + assert!(!result.contains("Analyzing:")); + assert!(!result.contains("INFO:")); + } + + #[test] + /// Test `bazel build` filtering on a build with errors. + fn test_filter_with_errors() { + let output = "\ Computing main repo mapping: Loading: Loading: 0 packages loaded -WARNING: Target pattern parsing failed. ERROR: Skipping '//src:bazel-dev-NONEXISTENT': no such target '//src:bazel-dev-NONEXISTENT' ERROR: no such target '//src:bazel-dev-NONEXISTENT': target 'bazel-dev-NONEXISTENT' not declared in package 'src' INFO: Elapsed time: 0.142s INFO: 0 processes. ERROR: Build did NOT complete successfully"; - let result = build("", stderr); - - assert!(result.contains("bazel build: 2 errors, 1 warning")); - assert!(result.contains("(0 actions)")); - assert!(result.contains("ERROR: Skipping")); - assert!(result.contains("ERROR: no such target")); - assert!(result.contains("WARNING: Target pattern parsing failed")); - // "Build did NOT complete successfully" is stripped (we have our own header) - assert!(!result.contains("Build did NOT complete successfully")); - // Noise stripped - assert!(!result.contains("Loading:")); - assert!(!result.contains("INFO:")); - } - - #[test] - fn test_filter_bazel_build_compiler_warnings() { - let stderr = "\ + let result = filter_bazel_build(output); + + // Build summary + assert!(result.contains("bazel build: 2 errors")); + + // Errors are printed + assert!(result.contains("ERROR: Skipping")); + assert!(result.contains("ERROR: no such target")); + + // "Build did NOT complete successfully" is stripped (we have our own header) + assert!(!result.contains("Build did NOT complete successfully")); + + // Noise stripped + assert!(!result.contains("Loading:")); + assert!(!result.contains("INFO:")); + } + + #[test] + /// Test `bazel build` filtering on a build with errors and warnings. + fn test_filter_with_errors_and_warnings() { + let output = "\ +Computing main repo mapping: +Loading: +Loading: 0 packages loaded +WARNING: /home/user/bazel/src/conditions/BUILD:119:15: select() on cpu is deprecated. +ERROR: Skipping '//src:bazel-dev-NONEXISTENT': no such target '//src:bazel-dev-NONEXISTENT' +ERROR: no such target '//src:bazel-dev-NONEXISTENT': target 'bazel-dev-NONEXISTENT' not declared in package 'src' +INFO: Elapsed time: 0.142s +INFO: 0 processes. +ERROR: Build did NOT complete successfully"; + let result = filter_bazel_build(output); + + // Build summary + assert!(result.contains("bazel build: 2 errors, 1 warning")); + + // Errors are printed + assert!(result.contains("ERROR: Skipping")); + assert!(result.contains("ERROR: no such target")); + + // Warnings are printed + assert!(result.contains("WARNING:")); + assert!(result.contains("select() on cpu is deprecated")); + + // "Build did NOT complete successfully" is stripped (we have our own header) + assert!(!result.contains("Build did NOT complete successfully")); + + // Noise stripped + assert!(!result.contains("Loading:")); + assert!(!result.contains("INFO:")); + } + + #[test] + fn test_filter_java_compiler_warnings() { + let output = "\ INFO: Analyzed target //src:bazel-dev (563 packages loaded). -[100 / 200] Compiling something.cc INFO: From Building external/protobuf+/java/core/liblite_runtime_only.jar (94 source files): bazel-out/k8-fastbuild/bin/src/main/protobuf/failure_details.pb.h:9953:111: warning: 'some_field' is deprecated [-Wdeprecated-declarations] - 9953 | [[deprecated]] static constexpr Code FIELD = value; - | ^~~~~ +9953 | [[deprecated]] static constexpr Code FIELD = value; + | ^~~~~ bazel-out/k8-fastbuild/bin/src/main/protobuf/failure_details.pb.h:1690:3: note: declared here - 1690 | SomeField [[deprecated]] = 2, - | ^~~~~~~~~ +1690 | SomeField [[deprecated]] = 2, + | ^~~~~~~~~ -[200 / 200] Linking src/main/cpp/client INFO: Build completed successfully, 200 total actions"; - let result = build("", stderr); - - // Should keep the compiler warning block - assert!(result.contains("warning:")); - assert!(result.contains("deprecated")); - assert!(result.contains("note: declared here")); - // Should show warning count - assert!(result.contains("1 warning")); - // Noise stripped - assert!(!result.contains("[100 / 200]")); - assert!(!result.contains("[200 / 200]")); - assert!(!result.contains("INFO:")); - } - - #[test] - fn test_filter_bazel_build_strips_progress() { - let stderr = "\ -[1 / 1] no actions running -[889 / 4,978] Compiling absl/numeric/int128.cc; 0s processwrapper-sandbox -[1,084 / 4,978] Compiling absl/time/internal/cctz/src/time_zone_info.cc; 1s -[4,976 / 4,978] Executing genrule //src:package-zip; 1s -INFO: Build completed successfully, 4978 total actions"; - let result = build("", stderr); + let result = filter_bazel_build(output); + + // Should keep the compiler warning block + assert!(result.contains("warning:")); + assert!(result.contains("deprecated")); + assert!(result.contains("note: declared here")); + // Should show warning count + assert!(result.contains("1 warning")); + // Noise stripped + assert!(!result.contains("INFO:")); + } - assert!(!result.contains("[889")); - assert!(!result.contains("[1,084")); - assert!(!result.contains("[4,976")); - assert!(!result.contains("[1 / 1]")); - assert!(result.contains("✓ bazel build (4978 actions)")); - } + #[test] + fn test_filter_rustc_compiler_warnings() { + let output = "\ +INFO: Analyzed target //src:rust_app (100 packages loaded). +warning: field `value` is never read +--> src/lib.rs:12:5 +| +12 | value: usize, +| ^^^^^ +| += note: `#[warn(dead_code)]` on by default + +INFO: Build completed successfully, 42 total actions"; + let result = filter_bazel_build(output); + + assert!(result.contains("warning: field `value` is never read")); + assert!(result.contains("src/lib.rs:12:5")); + assert!(result.contains("1 warning")); + assert!(!result.contains("INFO:")); + } + + #[test] + fn test_filter_gcc_compiler_warnings() { + let output = "\ +INFO: Analyzed target //src:gcc_app (32 packages loaded). +src/main.c:14:9: warning: unused variable 'tmp' [-Wunused-variable] +14 | int tmp = 0; + | ^~~ + +INFO: Build completed successfully, 7 total actions"; + let result = filter_bazel_build(output); + + assert!(result.contains("warning: unused variable 'tmp'")); + assert!(result.contains("src/main.c:14:9")); + assert!(result.contains("1 warning")); + assert!(!result.contains("INFO:")); + } + + #[test] + fn test_filter_clang_compiler_warnings() { + let output = "\ +INFO: Analyzed target //src:clang_app (48 packages loaded). +src/main.cc:21:7: warning: unused variable 'counter' [-Wunused-variable] +21 | int counter = 0; + | ^~~~~~~ +1 warning generated. + +INFO: Build completed successfully, 11 total actions"; + let result = filter_bazel_build(output); + + assert!(result.contains("warning: unused variable 'counter'")); + assert!(result.contains("src/main.cc:21:7")); + assert!(result.contains("1 warning")); + assert!(!result.contains("INFO:")); + } - #[test] - fn test_filter_bazel_build_strips_info_noise() { - let stderr = "\ + #[test] + fn test_filter_strips_info_noise() { + let stderr = "\ Computing main repo mapping: Loading: Loading: 1 packages loaded @@ -1740,31 +1932,31 @@ INFO: Build completed successfully, 100 total actions Note: Some input files use or override a deprecated API. Note: Recompile with -Xlint:removal for details. Target //src:bazel-dev up-to-date: - bazel-bin/src/bazel-dev +bazel-bin/src/bazel-dev DEBUG: some debug info"; - let result = build("", stderr); - - assert!(!result.contains("Computing main repo")); - assert!(!result.contains("Loading:")); - assert!(!result.contains("Analyzing:")); - assert!(!result.contains("INFO:")); - assert!(!result.contains("Note:")); - assert!(!result.contains("Target //src:bazel-dev up-to-date")); - assert!(!result.contains("bazel-bin/")); - assert!(!result.contains("DEBUG:")); - assert!(result.contains("✓ bazel build (100 actions)")); - } + let result = filter_bazel_build(stderr); + + assert!(!result.contains("Computing main repo")); + assert!(!result.contains("Loading:")); + assert!(!result.contains("Analyzing:")); + assert!(!result.contains("INFO:")); + assert!(!result.contains("Note:")); + assert!(!result.contains("Target //src:bazel-dev up-to-date")); + assert!(!result.contains("bazel-bin/")); + assert!(!result.contains("DEBUG:")); + assert!(result.contains("✓ bazel build (100 actions)")); + } - #[test] - fn test_filter_bazel_build_empty() { - let result = build("", ""); - assert_eq!(result, "✓ bazel build (0 actions)"); - } + #[test] + fn test_filter_empty() { + let result = filter_bazel_build(""); + assert_eq!(result, "✓ bazel build"); + } - #[test] - fn test_filter_bazel_build_token_savings() { - // Real-ish bazel build output (~80 lines of noise) - let stderr = "\ + #[test] + fn test_filter_token_savings() { + // Real-ish bazel build output (~80 lines of noise) + let output = "\ Computing main repo mapping: Loading: Loading: 1 packages loaded @@ -1775,188 +1967,178 @@ WARNING: /home/user/bazel/src/conditions/BUILD:202:15: select() on cpu is deprec WARNING: /home/user/bazel/src/conditions/BUILD:193:15: select() on cpu is deprecated. DEBUG: /home/user/.cache/bazel/external/grpc-java/java_grpc_library.bzl:202:14: Multiple values deprecated INFO: Analyzed target //src:bazel-dev (563 packages loaded, 24852 targets configured). -[1 / 1] no actions running -[889 / 4,978] Compiling absl/numeric/int128.cc; 0s processwrapper-sandbox ... (256 actions, 255 running) -[1,084 / 4,978] Compiling absl/time/internal/cctz/src/time_zone_info.cc; 1s processwrapper-sandbox ... (256 actions, 255 running) -[1,191 / 4,978] Compiling tools/cpp/modules_tools/common/common.cc; 2s processwrapper-sandbox ... (256 actions, 255 running) -[1,348 / 4,978] Executing genrule //src:embedded_jdk_allmodules; 3s processwrapper-sandbox ... (256 actions, 255 running) -[1,469 / 4,978] Executing genrule //src:embedded_jdk_allmodules; 4s processwrapper-sandbox ... (256 actions, 255 running) -[1,540 / 4,978] Executing genrule //src:embedded_jdk_allmodules; 6s processwrapper-sandbox ... (256 actions, 255 running) -[1,605 / 4,978] Executing genrule //src:embedded_jdk_allmodules; 7s processwrapper-sandbox ... (255 actions, 254 running) -[1,642 / 4,978] Executing genrule //src:embedded_jdk_allmodules; 8s processwrapper-sandbox ... (240 actions running) -[1,681 / 4,978] Executing genrule //src:embedded_jdk_allmodules; 9s processwrapper-sandbox ... (201 actions running) -[1,751 / 4,978] Executing genrule //src:embedded_jdk_allmodules; 10s processwrapper-sandbox ... (256 actions, 202 running) -[1,810 / 4,978] Executing genrule //src:embedded_jdk_allmodules; 12s processwrapper-sandbox ... (224 actions, 155 running) -[1,846 / 4,978] Executing genrule //src:embedded_jdk_allmodules; 13s processwrapper-sandbox ... (188 actions, 128 running) -[1,904 / 4,978] Executing genrule //src:embedded_jdk_allmodules; 14s processwrapper-sandbox ... (130 actions, 92 running) -[1,970 / 4,978] Executing genrule //src:embedded_jdk_allmodules; 15s processwrapper-sandbox ... (179 actions, 151 running) INFO: From Building external/zstd-jni+/libzstd-jni-class.jar (30 source files) [for tool]: Note: Some input files use or override a deprecated API that is marked for removal. Note: Recompile with -Xlint:removal for details. -[2,149 / 4,978] Executing genrule //src:embedded_jdk_allmodules; 16s processwrapper-sandbox ... (85 actions, 54 running) INFO: From Building external/zstd-jni+/libzstd-jni-class.jar (30 source files): Note: Some input files use or override a deprecated API that is marked for removal. Note: Recompile with -Xlint:removal for details. -[2,318 / 4,978] Executing genrule //src:embedded_jdk_allmodules; 17s processwrapper-sandbox -[2,346 / 4,978] Executing genrule //src:embedded_jdk_allmodules; 18s processwrapper-sandbox -[2,368 / 4,978] Executing genrule //src:embedded_jdk_allmodules; 19s processwrapper-sandbox -[4,974 / 4,978] Linking src/main/cpp/client; 1s processwrapper-sandbox -[4,976 / 4,978] Executing genrule //src:package-zip_jdk_allmodules; 1s processwrapper-sandbox INFO: Found 1 target... Target //src:bazel-dev up-to-date: - bazel-bin/src/bazel-dev +bazel-bin/src/bazel-dev INFO: Elapsed time: 54.859s, Critical Path: 49.98s INFO: 2391 processes: 3 internal, 1537 processwrapper-sandbox, 881 worker. INFO: Build completed successfully, 2391 total actions"; - let input_tokens = count_tokens(stderr); - let result = build("", stderr); - let output_tokens = count_tokens(&result); - - let savings = 100.0 - (output_tokens as f64 / input_tokens as f64 * 100.0); - assert!( - savings >= 60.0, - "Bazel build filter: expected ≥60% savings, got {:.1}% ({} → {} tokens)", - savings, - input_tokens, - output_tokens - ); - } - - #[test] - fn test_filter_bazel_build_truncates_blocks() { - // More than 15 issues should truncate - let mut stderr = String::new(); - for i in 0..20 { - stderr.push_str(&format!("ERROR: //pkg:target_{}: build failed\n\n", i)); + let input_tokens = count_tokens(output); + let result = filter_bazel_build(output); + let output_tokens = count_tokens(&result); + + let savings = 100.0 - (output_tokens as f64 / input_tokens as f64 * 100.0); + assert!( + savings >= 60.0, + "Bazel build filter: expected ≥60% savings, got {:.1}% ({} → {} tokens)", + savings, + input_tokens, + output_tokens + ); } - let result = build("", &stderr); - assert!(result.contains("... +5 more issues")); - } + #[test] + fn test_filter_truncates_blocks() { + // More than 15 issues should truncate + let mut stderr = String::new(); + for i in 0..20 { + stderr.push_str(&format!("ERROR: //pkg:target_{}: build failed\n\n", i)); + } + let result = filter_bazel_build(&stderr); - #[test] - fn test_filter_bazel_build_mixed_compiler_and_bazel_errors() { - let stderr = "\ + assert!(result.contains("... +5 more issues")); + } + + #[test] + fn test_filter_mixed_compiler_and_bazel_errors() { + let output = "\ WARNING: /home/user/bazel/BUILD:10:5: select() on cpu is deprecated. INFO: Analyzed target //src:app -[10 / 100] Compiling src/app.cc src/app.cc:42:10: error: use of undeclared identifier 'foo' - 42 | foo(); - | ^~~ +42 | foo(); + | ^~~ ERROR: //src:app failed to build INFO: Build completed, 0 total actions ERROR: Build did NOT complete successfully"; - let result = build("", stderr); - - // Should have 2 errors (compiler + bazel ERROR) and 1 warning - assert!(result.contains("2 errors")); - assert!(result.contains("1 warning")); - assert!(result.contains("error: use of undeclared identifier")); - assert!(result.contains("ERROR: //src:app failed to build")); - assert!(result.contains("WARNING:")); - assert!(result.contains("select() on cpu is deprecated")); + let result = filter_bazel_build(output); + + // Should have 2 errors (compiler + bazel ERROR) and 1 warning + assert!(result.contains("2 errors")); + assert!(result.contains("1 warning")); + assert!(result.contains("error: use of undeclared identifier")); + assert!(result.contains("ERROR: //src:app failed to build")); + assert!(result.contains("WARNING:")); + assert!(result.contains("select() on cpu is deprecated")); + } } /******************************************************************/ /* bazel query tests */ /******************************************************************/ - fn query(stdout: &str, stderr: &str, depth: usize, width: usize) -> String { - filter_bazel_query(stdout, stderr, depth, width) - } + mod query { + use super::*; - #[test] - fn test_limit_value() { - assert_eq!(Limit::N(5).value(), 5); - assert_eq!(Limit::All.value(), usize::MAX); - } + #[test] + fn test_limit_value() { + assert_eq!(Limit::N(5).value(), 5); + assert_eq!(Limit::All.value(), usize::MAX); + } - #[test] - fn test_limit_display() { - assert_eq!(Limit::N(5).to_string(), "5"); - assert_eq!(Limit::All.to_string(), "all"); - } + #[test] + fn test_limit_display() { + assert_eq!(Limit::N(5).to_string(), "5"); + assert_eq!(Limit::All.to_string(), "all"); + } + + #[test] + fn test_limit_from_str() { + assert_eq!("1".parse::().unwrap(), Limit::N(1)); + assert_eq!("10".parse::().unwrap(), Limit::N(10)); + assert_eq!("0".parse::().unwrap(), Limit::N(0)); + assert_eq!("all".parse::().unwrap(), Limit::All); + assert_eq!("ALL".parse::().unwrap(), Limit::All); + assert_eq!("All".parse::().unwrap(), Limit::All); + assert!("invalid".parse::().is_err()); + assert!("".parse::().is_err()); + } - #[test] - fn test_strips_info_warning_noise() { - let stderr = "\ -(10:23:45) INFO: Invocation ID: abc-123 -(10:23:45) INFO: Build options changed -(10:23:46) WARNING: some warning -(10:23:47) DEBUG: debug info + #[test] + fn test_strips_info_warning_noise() { + let stderr = "\ +INFO: Invocation ID: abc-123 +INFO: Build options changed +WARNING: some warning +DEBUG: debug info INFO: plain info line WARNING: plain warning DEBUG: plain debug"; - let stdout = "//pkg:target"; - let result = query(stdout, stderr, usize::MAX, usize::MAX); - - assert!(!result.contains("Invocation ID")); - assert!(!result.contains("Build options changed")); - assert!(!result.contains("some warning")); - assert!(!result.contains("debug info")); - assert!(!result.contains("plain info line")); - assert!(!result.contains("plain warning")); - assert!(!result.contains("plain debug")); - assert!(result.contains("🎯 :target")); - } - - #[test] - fn test_keeps_error_lines() { - let stderr = "\ -(10:23:45) INFO: Build options changed -(10:23:46) ERROR: something went wrong + let stdout = "//pkg:target"; + let result = filter_bazel_query(stdout, stderr, usize::MAX, usize::MAX); + + assert!(!result.contains("Invocation ID")); + assert!(!result.contains("Build options changed")); + assert!(!result.contains("some warning")); + assert!(!result.contains("debug info")); + assert!(!result.contains("plain info line")); + assert!(!result.contains("plain warning")); + assert!(!result.contains("plain debug")); + assert!(result.contains("🎯 :target")); + } + + #[test] + fn test_keeps_error_lines() { + let stderr = "\ +INFO: Build options changed +ERROR: something went wrong ERROR: another error"; - let stdout = "//pkg:target"; - let result = query(stdout, stderr, usize::MAX, usize::MAX); + let stdout = "//pkg:target"; + let result = filter_bazel_query(stdout, stderr, usize::MAX, usize::MAX); - assert!(result.contains("ERROR: something went wrong")); - assert!(result.contains("ERROR: another error")); - assert!(!result.contains("Build options changed")); - } + assert!(result.contains("ERROR: something went wrong")); + assert!(result.contains("ERROR: another error")); + assert!(!result.contains("Build options changed")); + } - #[test] - fn test_empty_output() { - let result = query("", "", usize::MAX, usize::MAX); - // With default root, header is still produced - assert!(result.contains("// (0 targets)")); - } + #[test] + fn test_empty_output() { + let result = filter_bazel_query("", "", usize::MAX, usize::MAX); + // With default root, header is still produced + assert!(result.contains("// (0 targets)")); + } - #[test] - fn test_non_target_lines_pass_through() { - let stdout = "\ + #[test] + fn test_non_target_lines_pass_through() { + let stdout = "\ //pkg:target_a some non-target output line //:root_target"; - let result = query(stdout, "", usize::MAX, usize::MAX); + let result = filter_bazel_query(stdout, "", usize::MAX, usize::MAX); - assert!(result.contains("some non-target output line")); - assert!(result.contains("🎯 :target_a")); - assert!(result.contains("🎯 :root_target")); - } + assert!(result.contains("some non-target output line")); + assert!(result.contains("🎯 :target_a")); + assert!(result.contains("🎯 :root_target")); + } - #[test] - fn test_single_target_uses_singular() { - let stdout = "//my/package:only_target"; - let result = query(stdout, "", usize::MAX, usize::MAX); - assert!(result.contains("(1 target)")); - } + #[test] + fn test_single_target_uses_singular() { + let stdout = "//my/package:only_target"; + let result = filter_bazel_query(stdout, "", usize::MAX, usize::MAX); + assert!(result.contains("(1 target)")); + } - #[test] - fn test_header_line() { - let stdout = "\ + #[test] + fn test_header_line() { + let stdout = "\ //src/lib:a //src/lib:b //tools:c"; - let result = query(stdout, "", usize::MAX, usize::MAX); + let result = filter_bazel_query(stdout, "", usize::MAX, usize::MAX); - assert!(result.contains("//src/lib (2 targets)")); - assert!(result.contains("//tools (1 target)")); - } + assert!(result.contains("//src/lib (2 targets)")); + assert!(result.contains("//tools (1 target)")); + } - #[test] - fn test_depth_1_collapses_to_summary() { - let stdout = "\ + #[test] + fn test_depth_1_collapses_to_summary() { + let stdout = "\ //src/lib:a //src/lib:b //src/app:c @@ -1964,63 +2146,63 @@ some non-target output line //tools/gen:e //tools/gen:f //:root_target"; - let result = query(stdout, "", 1, usize::MAX); + let result = filter_bazel_query(stdout, "", 1, usize::MAX); - assert!(result.contains("//src (3 targets, 2 packages)")); - assert!(result.contains("//tools/gen (3 targets)")); - assert!(result.contains("// (1 target)")); - assert!(result.contains("🎯 :root_target")); - } + assert!(result.contains("//src (3 targets, 2 packages)")); + assert!(result.contains("//tools/gen (3 targets)")); + assert!(result.contains("// (1 target)")); + assert!(result.contains("🎯 :root_target")); + } - #[test] - fn test_depth_2_shows_two_levels() { - let stdout = "\ + #[test] + fn test_depth_2_shows_two_levels() { + let stdout = "\ //src/lib/math:a //src/lib/math:b //src/lib/io:c //src/app:d //tools:e"; - let result = query(stdout, "", 2, usize::MAX); + let result = filter_bazel_query(stdout, "", 2, usize::MAX); - assert!(result.contains("//src/app (1 target)")); - assert!(result.contains("//src/lib (3 targets, 2 packages)")); - assert!(result.contains("//tools (1 target)")); - } + assert!(result.contains("//src/app (1 target)")); + assert!(result.contains("//src/lib (3 targets, 2 packages)")); + assert!(result.contains("//tools (1 target)")); + } - #[test] - fn test_depth_all_shows_everything() { - let stdout = "\ + #[test] + fn test_depth_all_shows_everything() { + let stdout = "\ //src/lib/math:a //src/lib/io:b //src/app:c"; - let result = query(stdout, "", usize::MAX, usize::MAX); - - assert!(result.contains("//src/app (1 target)")); - assert!(result.contains("//src/lib/io (1 target)")); - assert!(result.contains("//src/lib/math (1 target)")); - assert!(result.contains("🎯 :a")); - assert!(result.contains("🎯 :b")); - assert!(result.contains("🎯 :c")); - } + let result = filter_bazel_query(stdout, "", usize::MAX, usize::MAX); + + assert!(result.contains("//src/app (1 target)")); + assert!(result.contains("//src/lib/io (1 target)")); + assert!(result.contains("//src/lib/math (1 target)")); + assert!(result.contains("🎯 :a")); + assert!(result.contains("🎯 :b")); + assert!(result.contains("🎯 :c")); + } - #[test] - fn test_always_cumulative_counts() { - // Even when expanded, parent shows full subtree count - let stdout = "\ + #[test] + fn test_always_cumulative_counts() { + // Even when expanded, parent shows full subtree count + let stdout = "\ //examples/cpp:a //examples/cpp:b //examples/go:c //examples/java/sub:d"; - let result = query(stdout, "", 2, usize::MAX); + let result = filter_bazel_query(stdout, "", 2, usize::MAX); - assert!(result.contains("//examples/cpp (2 targets)")); - assert!(result.contains("//examples/go (1 target)")); - assert!(result.contains("//examples/java (1 target, 1 package)")); - } + assert!(result.contains("//examples/cpp (2 targets)")); + assert!(result.contains("//examples/go (1 target)")); + assert!(result.contains("//examples/java (1 target, 1 package)")); + } - #[test] - fn test_width_budget_packages_then_targets() { - let stdout = "\ + #[test] + fn test_width_budget_packages_then_targets() { + let stdout = "\ //root/a:t //root/b:t //root/c:t @@ -2028,40 +2210,40 @@ some non-target output line //root:root_a //root:root_b //root:root_c"; - let result = query(stdout, "", 1, 5); - - assert!(result.contains("//root (7 targets, 4 packages)")); - assert!(result.contains("📦 a (1 target)")); - assert!(result.contains("📦 b (1 target)")); - assert!(result.contains("📦 c (1 target)")); - assert!(result.contains("📦 d (1 target)")); - assert!(result.contains("🎯 :root_a")); - assert!(!result.contains("🎯 :root_b")); - assert!(result.contains("(+2 more targets)")); - } + let result = filter_bazel_query(stdout, "", 1, 5); + + assert!(result.contains("//root (7 targets, 4 packages)")); + assert!(result.contains("📦 a (1 target)")); + assert!(result.contains("📦 b (1 target)")); + assert!(result.contains("📦 c (1 target)")); + assert!(result.contains("📦 d (1 target)")); + assert!(result.contains("🎯 :root_a")); + assert!(!result.contains("🎯 :root_b")); + assert!(result.contains("(+2 more targets)")); + } - #[test] - fn test_width_limits_packages() { - let stdout = "\ + #[test] + fn test_width_limits_packages() { + let stdout = "\ //root/a:t1 //root/b:t2 //root/c:t3 //root/d:t4 //root/e:t5"; - let result = query(stdout, "", 1, 3); - - assert!(result.contains("//root (5 targets, 5 packages)")); - assert!(result.contains("📦 a")); - assert!(result.contains("📦 b")); - assert!(result.contains("📦 c")); - assert!(!result.contains("📦 d")); - assert!(!result.contains("📦 e")); - assert!(result.contains("(+2 more sub-packages)")); - } + let result = filter_bazel_query(stdout, "", 1, 3); + + assert!(result.contains("//root (5 targets, 5 packages)")); + assert!(result.contains("📦 a")); + assert!(result.contains("📦 b")); + assert!(result.contains("📦 c")); + assert!(!result.contains("📦 d")); + assert!(!result.contains("📦 e")); + assert!(result.contains("(+2 more sub-packages)")); + } - #[test] - fn test_condensed_truncation_line() { - let stdout = "\ + #[test] + fn test_condensed_truncation_line() { + let stdout = "\ //root/a:t //root/b:t //root/c:t @@ -2069,77 +2251,77 @@ some non-target output line //root:x //root:y //root:z"; - let result = query(stdout, "", 1, 3); + let result = filter_bazel_query(stdout, "", 1, 3); - assert!(result.contains("(+1 more sub-package, 3 more targets)")); - } + assert!(result.contains("(+1 more sub-package, 3 more targets)")); + } - #[test] - fn test_condensed_truncation_omits_zero_parts() { - let stdout = "\ + #[test] + fn test_condensed_truncation_omits_zero_parts() { + let stdout = "\ //root/a:t //root/b:t //root/c:t //root/d:t"; - let result = query(stdout, "", 1, 3); + let result = filter_bazel_query(stdout, "", 1, 3); - assert!(result.contains("(+1 more sub-package)")); - assert!(!result.contains("more target")); - } + assert!(result.contains("(+1 more sub-package)")); + assert!(!result.contains("more target")); + } - #[test] - fn test_root_targets_inline() { - let stdout = "\ + #[test] + fn test_root_targets_inline() { + let stdout = "\ //:bazel-distfile //:bazel-srcs //src:lib"; - let result = query(stdout, "", 1, usize::MAX); + let result = filter_bazel_query(stdout, "", 1, usize::MAX); - assert!(result.contains("//src (1 target)")); - assert!(result.contains("// (2 targets)")); - assert!(result.contains("🎯 :bazel-distfile")); - assert!(result.contains("🎯 :bazel-srcs")); - } + assert!(result.contains("//src (1 target)")); + assert!(result.contains("// (2 targets)")); + assert!(result.contains("🎯 :bazel-distfile")); + assert!(result.contains("🎯 :bazel-srcs")); + } - #[test] - fn test_relative_names() { - let stdout = "\ + #[test] + fn test_relative_names() { + let stdout = "\ //examples/cpp:a //examples/go:b"; - let result = query(stdout, "", 2, usize::MAX); + let result = filter_bazel_query(stdout, "", 2, usize::MAX); - assert!(result.contains("//examples/cpp (1 target)")); - assert!(result.contains("//examples/go (1 target)")); - } + assert!(result.contains("//examples/cpp (1 target)")); + assert!(result.contains("//examples/go (1 target)")); + } - #[test] - fn test_groups_targets_by_package() { - let stdout = "\ + #[test] + fn test_groups_targets_by_package() { + let stdout = "\ //src/lib/math/compute:target_a //src/lib/math/compute:target_b //src/lib/math/compute:target_c //tools/codegen:foo //tools/codegen:bar"; - let result = query(stdout, "", usize::MAX, usize::MAX); - - // With full depth, targets are at leaf nodes - assert!(result.contains("🎯 :target_a")); - assert!(result.contains("🎯 :target_b")); - assert!(result.contains("🎯 :target_c")); - assert!(result.contains("🎯 :foo")); - assert!(result.contains("🎯 :bar")); - } - - #[test] - fn test_real_bazel_output() { - let stderr = "\ -(10:23:45) INFO: Invocation ID: 8e2f4a91-abc1-4def-9012-345678abcdef -(10:23:45) INFO: Current date is 2026-03-01 -(10:23:46) WARNING: Build option --config=remote has changed -(10:23:46) INFO: Repository rule @bazel_tools//tools/jdk:jdk configured -(10:23:47) INFO: Found 16 targets... -(10:23:47) INFO: Elapsed time: 1.234s"; - let stdout = "\ + let result = filter_bazel_query(stdout, "", usize::MAX, usize::MAX); + + // With full depth, targets are at leaf nodes + assert!(result.contains("🎯 :target_a")); + assert!(result.contains("🎯 :target_b")); + assert!(result.contains("🎯 :target_c")); + assert!(result.contains("🎯 :foo")); + assert!(result.contains("🎯 :bar")); + } + + #[test] + fn test_real_bazel_output() { + let stderr = "\ +INFO: Invocation ID: 8e2f4a91-abc1-4def-9012-345678abcdef +INFO: Current date is 2026-03-01 +WARNING: Build option --config=remote has changed +INFO: Repository rule @bazel_tools//tools/jdk:jdk configured +INFO: Found 16 targets... +INFO: Elapsed time: 1.234s"; + let stdout = "\ //src/app/foo/bar:bar //src/app/foo/bar:bar_test //src/app/foo/bar:bar_lib @@ -2157,267 +2339,258 @@ some non-target output line //src/app/foo/bar:runner //src/app/foo/bar:runner_test"; - let result = query(stdout, stderr, usize::MAX, usize::MAX); + let result = filter_bazel_query(stdout, stderr, usize::MAX, usize::MAX); - // Should strip all INFO/WARNING noise - assert!(!result.contains("Invocation ID")); - assert!(!result.contains("Elapsed time")); + // Should strip all INFO/WARNING noise + assert!(!result.contains("Invocation ID")); + assert!(!result.contains("Elapsed time")); - assert!(result.contains("//src/app/foo/bar (16 targets)")); + assert!(result.contains("//src/app/foo/bar (16 targets)")); - assert!(result.contains("🎯 :bar\n")); - assert!(result.contains("🎯 :runner_test")); - } + assert!(result.contains("🎯 :bar\n")); + assert!(result.contains("🎯 :runner_test")); + } - #[test] - fn test_filter_bazel_query_multi_root_no_target_loss() { - let stdout = "\ + #[test] + fn test_filter_multi_root_no_target_loss() { + let stdout = "\ //src/app:bin //tools/gen:tool //third_party/lib:pkg"; - let result = filter_bazel_query(stdout, "", usize::MAX, usize::MAX); - - assert!(result.contains("//src/app (1 target)")); - assert!(result.contains("//tools/gen (1 target)")); - assert!(result.contains("//third_party/lib (1 target)")); - assert!(result.contains("🎯 :bin")); - assert!(result.contains("🎯 :tool")); - assert!(result.contains("🎯 :pkg")); - } + let result = filter_bazel_query(stdout, "", usize::MAX, usize::MAX); + + assert!(result.contains("//src/app (1 target)")); + assert!(result.contains("//tools/gen (1 target)")); + assert!(result.contains("//third_party/lib (1 target)")); + assert!(result.contains("🎯 :bin")); + assert!(result.contains("🎯 :tool")); + assert!(result.contains("🎯 :pkg")); + } - #[test] - fn test_filter_bazel_query_multi_root_respects_width() { - let stdout = "\ + #[test] + fn test_filter_multi_root_respects_width() { + let stdout = "\ //src/s1:a //src/s2:b //tools/t1:c //tools/t2:d"; - let result = filter_bazel_query(stdout, "", 1, 1); + let result = filter_bazel_query(stdout, "", 1, 1); - assert!( - result.contains("//src (2 targets"), - "unexpected output:\n{}", - result - ); - assert!( - result.contains("//tools (2 targets"), - "unexpected output:\n{}", - result - ); - // Width 1 at each section root: one child package shown, one hidden. - assert_eq!(result.matches("(+1 more sub-package)").count(), 2); - assert!(result.contains("📦 s1 (1 target)")); - assert!(result.contains("📦 t1 (1 target)")); - } + assert!( + result.contains("//src (2 targets"), + "unexpected output:\n{}", + result + ); + assert!( + result.contains("//tools (2 targets"), + "unexpected output:\n{}", + result + ); + // Width 1 at each section root: one child package shown, one hidden. + assert_eq!(result.matches("(+1 more sub-package)").count(), 2); + assert!(result.contains("📦 s1 (1 target)")); + assert!(result.contains("📦 t1 (1 target)")); + } - #[test] - fn test_filter_bazel_query_groups_external_repos() { - let stdout = "\ + #[test] + fn test_filter_groups_external_repos() { + let stdout = "\ //src/app:bin @abseil-cpp//absl/base:core_headers @abseil-cpp//absl/strings:str_format @zlib//:zlib"; - let result = filter_bazel_query(stdout, "", 1, 10); - - assert!(result.contains("//src/app (1 target)")); - assert!(result.contains("@abseil-cpp//absl (2 targets")); - assert!(result.contains("@zlib// (1 target)")); - assert!(result.contains("📦 base (1 target)")); - assert!(result.contains("📦 strings (1 target)")); - assert!(result.contains("🎯 :zlib")); - } + let result = filter_bazel_query(stdout, "", 1, 10); + + assert!(result.contains("//src/app (1 target)")); + assert!(result.contains("@abseil-cpp//absl (2 targets")); + assert!(result.contains("@zlib// (1 target)")); + assert!(result.contains("📦 base (1 target)")); + assert!(result.contains("📦 strings (1 target)")); + assert!(result.contains("🎯 :zlib")); + } - #[test] - fn test_filter_bazel_query_consolidates_deep_common_prefix() { - let stdout = "\ + #[test] + fn test_filter_consolidates_deep_common_prefix() { + let stdout = "\ //src/java_tools/buildjar:a //src/java_tools/import_deps_checker:b //src/java_tools/junitrunner:c"; - let result = filter_bazel_query(stdout, "", 1, 10); + let result = filter_bazel_query(stdout, "", 1, 10); - assert!(result.starts_with("//src/java_tools (3 targets")); - assert!(result.contains("📦 buildjar (1 target)")); - assert!(result.contains("📦 import_deps_checker (1 target)")); - assert!(result.contains("📦 junitrunner (1 target)")); - } + assert!(result.starts_with("//src/java_tools (3 targets")); + assert!(result.contains("📦 buildjar (1 target)")); + assert!(result.contains("📦 import_deps_checker (1 target)")); + assert!(result.contains("📦 junitrunner (1 target)")); + } - #[test] - fn test_filter_bazel_query_splits_external_repos_by_repo_root() { - let stdout = "\ + #[test] + fn test_filter_splits_external_repos_by_repo_root() { + let stdout = "\ @abseil-cpp//absl/base:core @abseil-cpp//absl/strings:format @bazel_skylib//lib:paths @bazel_skylib//rules:copy"; - let result = filter_bazel_query(stdout, "", 1, 10); - - assert!(result.contains("@abseil-cpp//absl (2 targets")); - assert!(result.contains("@bazel_skylib// (2 targets, 2 packages)")); - assert!(result.contains("📦 base (1 target)")); - assert!(result.contains("📦 strings (1 target)")); - assert!(result.contains("📦 lib (1 target)")); - assert!(result.contains("📦 rules (1 target)")); - } + let result = filter_bazel_query(stdout, "", 1, 10); + + assert!(result.contains("@abseil-cpp//absl (2 targets")); + assert!(result.contains("@bazel_skylib// (2 targets, 2 packages)")); + assert!(result.contains("📦 base (1 target)")); + assert!(result.contains("📦 strings (1 target)")); + assert!(result.contains("📦 lib (1 target)")); + assert!(result.contains("📦 rules (1 target)")); + } - #[test] - fn test_filter_bazel_query_external_root_targets_keep_repo_root_header() { - let stdout = "\ + #[test] + fn test_filter_external_root_targets_keep_repo_root_header() { + let stdout = "\ @abseil-cpp//:root_target @abseil-cpp//absl/base:core"; - let result = filter_bazel_query(stdout, "", 1, 10); + let result = filter_bazel_query(stdout, "", 1, 10); - assert!(result.starts_with("@abseil-cpp// (2 targets, 2 packages)")); - assert!(result.contains("📦 absl (1 target, 1 package)")); - assert!(result.contains("🎯 :root_target")); - } + assert!(result.starts_with("@abseil-cpp// (2 targets, 2 packages)")); + assert!(result.contains("📦 absl (1 target, 1 package)")); + assert!(result.contains("🎯 :root_target")); + } - #[test] - fn test_filter_bazel_query_depth_1_runtime_mode_stays_single_section() { - let stdout = "\ + #[test] + fn test_filter_depth_1_runtime_mode_stays_single_section() { + let stdout = "\ //src:root //src/conditions:a //src/java_tools:b"; - let result = filter_bazel_query(stdout, "", 1, 10); + let result = filter_bazel_query(stdout, "", 1, 10); - assert!(result.starts_with("//src (3 targets, 2 packages)")); - assert!(result.contains("📦 conditions (1 target)")); - assert!(result.contains("📦 java_tools (1 target)")); - assert!(result.contains("🎯 :root")); - assert!(!result.contains("...")); - } + assert!(result.starts_with("//src (3 targets, 2 packages)")); + assert!(result.contains("📦 conditions (1 target)")); + assert!(result.contains("📦 java_tools (1 target)")); + assert!(result.contains("🎯 :root")); + assert!(!result.contains("...")); + } - #[test] - fn test_filter_bazel_query_depth_2_runtime_mode_expands_to_sections() { - let stdout = "\ + #[test] + fn test_filter_depth_2_runtime_mode_expands_to_sections() { + let stdout = "\ //src:root_a //src:root_b //src/conditions:c1 //src/java_tools:j1 //src/java_tools/sub:s1"; - let result = filter_bazel_query(stdout, "", 2, 10); - - assert!(result.contains("//src (2 targets)")); - assert!(result.contains("🎯 :root_a")); - assert!(result.contains("🎯 :root_b")); - assert!(result.contains("//src/conditions (1 target)")); - assert!(result.contains("//src/java_tools (2 targets, 1 package)")); - // Depth sections are flat; no tree indentation in this mode. - assert!(!result.contains(" 📦")); - } + let result = filter_bazel_query(stdout, "", 2, 10); + + assert!(result.contains("//src (2 targets)")); + assert!(result.contains("🎯 :root_a")); + assert!(result.contains("🎯 :root_b")); + assert!(result.contains("//src/conditions (1 target)")); + assert!(result.contains("//src/java_tools (2 targets, 1 package)")); + // Depth sections are flat; no tree indentation in this mode. + assert!(!result.contains(" 📦")); + } - #[test] - fn test_filter_bazel_query_depth_2_skips_empty_intermediate_section() { - let stdout = "\ + #[test] + fn test_filter_depth_2_skips_empty_intermediate_section() { + let stdout = "\ @xds+//xds/data/orca:alpha @xds+//xds/data/orca:beta @xds+//xds/service/orca:gamma @xds+//xds/service/orca:delta"; - let result = filter_bazel_query(stdout, "", 2, 10); + let result = filter_bazel_query(stdout, "", 2, 10); - assert!(!result.contains("@xds+//xds (0 targets)")); - assert!(result.contains("@xds+//xds/data")); - assert!(result.contains("@xds+//xds/service")); - } + assert!(!result.contains("@xds+//xds (0 targets)")); + assert!(result.contains("@xds+//xds/data")); + assert!(result.contains("@xds+//xds/service")); + } - #[test] - fn test_filter_bazel_query_error_only_no_empty_header() { - let stderr = - "ERROR: Evaluation of query \"deps(//...)\" failed: preloading transitive closure failed"; - let result = filter_bazel_query("", stderr, usize::MAX, 10); + #[test] + fn test_filter_error_only_no_empty_header() { + let stderr = + "ERROR: Evaluation of query \"deps(//...)\" failed: preloading transitive closure failed"; + let result = filter_bazel_query("", stderr, usize::MAX, 10); - assert_eq!( - result, - "ERROR: Evaluation of query \"deps(//...)\" failed: preloading transitive closure failed" - ); - assert!(!result.contains("//... (0 targets)")); - } + assert_eq!( + result, + "ERROR: Evaluation of query \"deps(//...)\" failed: preloading transitive closure failed" + ); + assert!(!result.contains("//... (0 targets)")); + } - #[test] - fn test_filter_bazel_query_single_root_uses_subsections() { - let stdout = "\ + #[test] + fn test_filter_single_root_uses_subsections() { + let stdout = "\ //src/lib:a //src/app:b"; - let result = filter_bazel_query(stdout, "", 2, usize::MAX); + let result = filter_bazel_query(stdout, "", 2, usize::MAX); - assert!(result.contains("//src/app (1 target)")); - assert!(result.contains("//src/lib (1 target)")); - assert!(result.contains("🎯 :a")); - assert!(result.contains("🎯 :b")); + assert!(result.contains("//src/app (1 target)")); + assert!(result.contains("//src/lib (1 target)")); + assert!(result.contains("🎯 :a")); + assert!(result.contains("🎯 :b")); + } } /******************************************************************/ /* bazel test tests */ /******************************************************************/ - fn btest(stdout: &str, stderr: &str) -> String { - filter_bazel_test(stdout, stderr) - } - - #[test] - fn test_filter_bazel_test_all_pass() { - let stderr = "\ -Computing main repo mapping: -Loading: -Loading: 0 packages loaded -Analyzing: 3 targets (81 packages loaded, 684 targets configured) -INFO: Analyzed 3 targets (81 packages loaded, 684 targets configured). -INFO: Found 3 test targets... -[0 / 4] [Prepa] BazelWorkspaceStatusAction stable-status.txt -[5 / 14] Compiling src/test/java/com/google/devtools/build/lib/util/CommandUtilsTest.java; 0s worker -[14 / 14] 3 tests, 1 action running -//src/test/java/com/google/devtools/build/lib/util:CommandUtilsTest PASSED in 0.3s -//src/test/java/com/google/devtools/build/lib/util:DecimalBucketerTest PASSED in 0.3s -//src/test/java/com/google/devtools/build/lib/util:StringEncodingTest PASSED in 0.3s -INFO: Elapsed time: 5.164s, Critical Path: 3.89s -INFO: 6 processes: 3 internal, 3 worker. -INFO: Build completed successfully, 6 total actions -Executed 3 out of 3 tests: 3 tests pass."; - let result = btest("", stderr); - assert_eq!(result, "\u{2713} bazel test: 3 passed, 0 failed (5.164s)"); - } + mod test { + use super::*; + + #[test] + fn test_filter_all_pass() { + let original = "\ +INFO: Analyzed 3 targets (0 packages loaded, 0 targets configured). +INFO: Found 1 target and 2 test targets... +INFO: Elapsed time: 0.508s, Critical Path: 0.00s +INFO: 1 process: 2 action cache hit, 1 internal. +INFO: Build completed successfully, 1 total action +//src/test/java/com/google/devtools/build/lib/runtime/commands/info:RemoteRequestedInfoItemHandlerTest PASSED in 2.4s +//src/test/java/com/google/devtools/build/lib/runtime/commands/info:StdoutInfoItemHandlerTest PASSED in 1.5s."; + let result = filter_bazel_test(original); + assert_eq!(result, "\u{2713} bazel test: 2 passed, 0 failed (0.508s)"); + } - #[test] - fn test_filter_bazel_test_with_cached() { - let stderr = "\ + #[test] + fn test_filter_with_cached() { + let stderr = "\ Loading: INFO: Analyzed 2 targets (0 packages loaded, 0 targets configured). INFO: Found 2 test targets... -//src/test/java/com/google/devtools/build/lib/util:CommandUtilsTest (cached) PASSED in 0.3s -//src/test/java/com/google/devtools/build/lib/util:StringEncodingTest PASSED in 0.1s +//src/test/java/com/google/devtools/build/lib/util:CommandUtilsTest PASSED in 0.3s +//src/test/java/com/google/devtools/build/lib/util:StringEncodingTest (cached) PASSED in 0.1s INFO: Elapsed time: 0.412s, Critical Path: 0.10s INFO: 2 processes: 1 internal, 1 worker. INFO: Build completed successfully, 2 total actions Executed 1 out of 2 tests: 2 tests pass."; - let result = btest("", stderr); - assert_eq!(result, "\u{2713} bazel test: 2 passed, 0 failed (0.412s)"); - } + let result = filter_bazel_test(stderr); + assert_eq!(result, "\u{2713} bazel test: 2 passed, 0 failed (0.412s)"); + } - #[test] - fn test_filter_bazel_test_failure() { - let stderr = "\ + #[test] + fn test_filter_failure() { + let stderr = "\ Loading: INFO: Analyzed 1 target (0 packages loaded, 0 targets configured). INFO: Found 1 test target... FAIL: //src/test/java/com/google/devtools/build/lib/util:StringEncodingTest (Exit 1) (see /home/user/.cache/bazel/_bazel_user/abc/execroot/io_bazel/bazel-out/k8-fastbuild/testlogs/src/test/java/com/google/devtools/build/lib/util/StringEncodingTest/test.log) //src/test/java/com/google/devtools/build/lib/util:StringEncodingTest FAILED in 0.3s - /home/user/.cache/bazel/testlogs/src/test/java/com/google/devtools/build/lib/util/StringEncodingTest/test.log +/home/user/.cache/bazel/testlogs/src/test/java/com/google/devtools/build/lib/util/StringEncodingTest/test.log INFO: Elapsed time: 0.340s, Critical Path: 0.30s INFO: 2 processes: 1 internal, 1 worker. INFO: Build completed, 1 test FAILED, 2 total actions Executed 1 out of 1 test: 1 fails locally."; - let result = btest("", stderr); - - assert!(result.contains("bazel test: 1 failed, 0 passed (0.340s)")); - assert!(result.contains("═══════════════════════════════════════")); - assert!(result.contains("FAIL: //src/test/java")); - assert!(result.contains("FAILED in 0.3s")); - // Noise stripped - assert!(!result.contains("Loading:")); - assert!(!result.contains("INFO:")); - assert!(!result.contains("Executed 1 out of")); - } + let result = filter_bazel_test(stderr); + + assert!(result.contains("bazel test: 1 failed, 0 passed (0.340s)")); + assert!(result.contains("═══════════════════════════════════════")); + assert!(result.contains("FAIL: //src/test/java")); + assert!(result.contains("FAILED in 0.3s")); + // Noise stripped + assert!(!result.contains("Loading:")); + assert!(!result.contains("INFO:")); + assert!(!result.contains("Executed 1 out of")); + } - #[test] - fn test_filter_bazel_test_failure_with_test_output() { - let stderr = "\ + #[test] + fn test_filter_failure_with_test_output() { + let stderr = "\ Loading: INFO: Analyzed 1 target (0 packages loaded, 0 targets configured). INFO: Found 1 test target... @@ -2436,25 +2609,25 @@ java.lang.Exception: No tests found matching RegEx[NONEXISTENT_TEST] INFO: Elapsed time: 0.340s, Critical Path: 0.30s INFO: Build completed, 1 test FAILED, 2 total actions Executed 1 out of 1 test: 1 fails locally."; - let result = btest("", stderr); - - assert!(result.contains("bazel test: 1 failed, 0 passed")); - // Inline test output preserved - assert!(result.contains("==================== Test output for")); - assert!(result.contains("JUnit4 Test Runner")); - assert!(result.contains("No tests found matching")); - assert!(result.contains( - "================================================================================" - )); - // FAIL line preserved - assert!(result.contains("FAIL: //src/test/java")); - // Result line preserved - assert!(result.contains("FAILED in 0.3s")); - } + let result = filter_bazel_test(stderr); + + assert!(result.contains("bazel test: 1 failed, 0 passed")); + // Inline test output preserved + assert!(result.contains("==================== Test output for")); + assert!(result.contains("JUnit4 Test Runner")); + assert!(result.contains("No tests found matching")); + assert!(result.contains( + "================================================================================" + )); + // FAIL line preserved + assert!(result.contains("FAIL: //src/test/java")); + // Result line preserved + assert!(result.contains("FAILED in 0.3s")); + } - #[test] - fn test_filter_bazel_test_strips_build_noise() { - let stderr = "\ + #[test] + fn test_filter_strips_build_noise() { + let stderr = "\ Computing main repo mapping: Loading: Loading: 0 packages loaded @@ -2467,31 +2640,31 @@ INFO: Found 1 test target... DEBUG: /some/debug/info Note: Some input files use deprecated API. Target //src:target up-to-date: - bazel-bin/src/target +bazel-bin/src/target //pkg:test PASSED in 0.5s INFO: Elapsed time: 1.00s, Critical Path: 0.50s INFO: Build completed successfully, 4 total actions Executed 1 out of 1 test: 1 tests pass."; - let result = btest("", stderr); - - assert!(!result.contains("Computing main repo")); - assert!(!result.contains("Loading:")); - assert!(!result.contains("Analyzing:")); - assert!(!result.contains("[0 / 4]")); - assert!(!result.contains("[5 / 14]")); - assert!(!result.contains("[14 / 14]")); - assert!(!result.contains("INFO:")); - assert!(!result.contains("DEBUG:")); - assert!(!result.contains("Note:")); - assert!(!result.contains("Target //src:target")); - assert!(!result.contains("bazel-bin/")); - assert!(!result.contains("Executed 1 out of")); - assert!(result.contains("\u{2713} bazel test: 1 passed, 0 failed")); - } - - #[test] - fn test_filter_bazel_test_build_error() { - let stderr = "\ + let result = filter_bazel_test(stderr); + + assert!(!result.contains("Computing main repo")); + assert!(!result.contains("Loading:")); + assert!(!result.contains("Analyzing:")); + assert!(!result.contains("[0 / 4]")); + assert!(!result.contains("[5 / 14]")); + assert!(!result.contains("[14 / 14]")); + assert!(!result.contains("INFO:")); + assert!(!result.contains("DEBUG:")); + assert!(!result.contains("Note:")); + assert!(!result.contains("Target //src:target")); + assert!(!result.contains("bazel-bin/")); + assert!(!result.contains("Executed 1 out of")); + assert!(result.contains("\u{2713} bazel test: 1 passed, 0 failed")); + } + + #[test] + fn test_filter_build_error() { + let stderr = "\ Loading: WARNING: Target pattern parsing failed. ERROR: Skipping '//src:nonexistent': no such target '//src:nonexistent' @@ -2499,25 +2672,25 @@ ERROR: no such target '//src:nonexistent': target 'nonexistent' not declared INFO: Elapsed time: 0.142s INFO: 0 processes. ERROR: Build did NOT complete successfully"; - let result = btest("", stderr); - - assert!(result.contains("bazel test: build failed")); - assert!(result.contains("═══════════════════════════════════════")); - assert!(result.contains("ERROR: Skipping")); - assert!(result.contains("ERROR: no such target")); - // "Build did NOT complete successfully" stripped - assert!(!result.contains("Build did NOT complete successfully")); - } + let result = filter_bazel_test(stderr); + + assert!(result.contains("bazel test: build failed")); + assert!(result.contains("═══════════════════════════════════════")); + assert!(result.contains("ERROR: Skipping")); + assert!(result.contains("ERROR: no such target")); + // "Build did NOT complete successfully" stripped + assert!(!result.contains("Build did NOT complete successfully")); + } - #[test] - fn test_filter_bazel_test_empty() { - let result = btest("", ""); - assert_eq!(result, "\u{2713} bazel test: 0 passed, 0 failed (0s)"); - } + #[test] + fn test_filter_empty() { + let result = filter_bazel_test(""); + assert_eq!(result, "\u{2713} bazel test: 0 passed, 0 failed (0s)"); + } - #[test] - fn test_filter_bazel_test_token_savings() { - let stderr = "\ + #[test] + fn test_filter_token_savings() { + let stderr = "\ Computing main repo mapping: Loading: Loading: 0 packages loaded @@ -2540,23 +2713,23 @@ INFO: 6 processes: 3 internal, 3 worker. INFO: Build completed successfully, 6 total actions Executed 3 out of 3 tests: 3 tests pass."; - let input_tokens = count_tokens(stderr); - let result = btest("", stderr); - let output_tokens = count_tokens(&result); - - let savings = 100.0 - (output_tokens as f64 / input_tokens as f64 * 100.0); - assert!( - savings >= 60.0, - "Bazel test filter: expected ≥60% savings, got {:.1}% ({} → {} tokens)", - savings, - input_tokens, - output_tokens - ); - } + let input_tokens = count_tokens(stderr); + let result = filter_bazel_test(stderr); + let output_tokens = count_tokens(&result); + + let savings = 100.0 - (output_tokens as f64 / input_tokens as f64 * 100.0); + assert!( + savings >= 60.0, + "Bazel test filter: expected ≥60% savings, got {:.1}% ({} → {} tokens)", + savings, + input_tokens, + output_tokens + ); + } - #[test] - fn test_filter_bazel_test_strips_timeout_warnings() { - let stderr = "\ + #[test] + fn test_filter_strips_timeout_warnings() { + let stderr = "\ INFO: Analyzed 1 target (0 packages loaded). INFO: Found 1 test target... //pkg:test PASSED in 0.5s @@ -2564,202 +2737,140 @@ There were tests whose specified size is too big. Use the --test_verbose_timeout INFO: Elapsed time: 1.00s, Critical Path: 0.50s INFO: Build completed successfully, 2 total actions Executed 1 out of 1 test: 1 tests pass."; - let result = btest("", stderr); + let result = filter_bazel_test(stderr); - assert!(!result.contains("There were tests whose specified size")); - assert!(!result.contains("--test_verbose_timeout_warnings")); - assert!(result.contains("\u{2713} bazel test: 1 passed, 0 failed")); + assert!(!result.contains("There were tests whose specified size")); + assert!(!result.contains("--test_verbose_timeout_warnings")); + assert!(result.contains("\u{2713} bazel test: 1 passed, 0 failed")); + } } /******************************************************************/ /* bazel run tests */ /******************************************************************/ - fn brun(stdout: &str, stderr: &str) -> String { - brun_with_args(stdout, stderr, &[]) - } + mod run { + use super::*; - fn brun_with_args(stdout: &str, stderr: &str, args: &[String]) -> String { - filter_bazel_run(stdout, stderr, args) - } - - #[test] - fn test_filter_bazel_run_success() { - let stderr = "\ -Computing main repo mapping: -Loading: -Loading: 0 packages loaded -Analyzing: target //src:my_binary (6 packages loaded) + #[test] + /// Test a successful `bazel run`. + fn test_filter_success() { + let output = "\ INFO: Analyzed target //src:my_binary (81 packages loaded, 684 targets configured). -[0 / 4] [Prepa] BazelWorkspaceStatusAction stable-status.txt -[10 / 14] Compiling src/main.cc INFO: Found 1 target... Target //src:my_binary up-to-date: - bazel-bin/src/my_binary +bazel-bin/src/my_binary INFO: Elapsed time: 3.50s, Critical Path: 2.10s INFO: 123 processes: 3 internal, 120 processwrapper-sandbox. INFO: Build completed successfully, 123 total actions INFO: Running command line: bazel-bin/src/my_binary -binary stderr line"; - let stdout = "Hello from binary!\nResult: 42"; - let args: Vec = vec!["//src:my_binary".into()]; - let result = brun_with_args(stdout, stderr, &args); - - // Clean build — no build summary, just binary output - assert!(!result.contains("bazel build")); - assert!(!result.contains("═══════════════════════════════════════")); - assert!(result.contains("Hello from binary!")); - assert!(result.contains("Result: 42")); - assert!(result.contains("binary stderr line")); - // Noise stripped - assert!(!result.contains("Loading:")); - assert!(!result.contains("[10 / 14]")); - assert!(!result.contains("INFO:")); - assert!(!result.contains("Computing main repo")); - } - - #[test] - fn test_filter_bazel_run_warnings_stripped() { - let stderr = "\ +Hello from binary! +Result: 42 +"; + let result = filter_bazel_run(output); + + // Clean output — no build summary, just binary output + assert_eq!(result, "Hello from binary!\nResult: 42\n"); + } + + #[test] + /// Test that a successful `bazel run` ignores build warnings. + fn test_filter_success_ignores_warnings() { + let output = "\ WARNING: /home/user/BUILD:10:5: select() on cpu is deprecated. WARNING: /home/user/BUILD:20:5: another deprecation warning. INFO: Analyzed target //src:app (10 packages loaded). -[5 / 10] Compiling something.cc INFO: Found 1 target... Target //src:app up-to-date: - bazel-bin/src/app +bazel-bin/src/app INFO: Build completed successfully, 100 total actions INFO: Running command line: bazel-bin/src/app app output here"; - let stdout = "app stdout"; - let result = brun(stdout, stderr); + let result = filter_bazel_run(output); - // Warnings stripped — clean build, no build section - assert!(!result.contains("WARNING:")); - assert!(!result.contains("select() on cpu")); - assert!(!result.contains("bazel build")); - // Binary output only - assert!(result.contains("app stdout")); - assert!(result.contains("app output here")); - } + // Clean output - no warnings shown + assert_eq!(result, "app output here"); + } - #[test] - fn test_filter_bazel_run_build_error() { - let stderr = "\ -Loading: -WARNING: Target pattern parsing failed. + #[test] + /// Test a failed `bazel run` with one error. + fn test_filter_with_one_error() { + let output = "\ ERROR: Skipping '//src:nonexistent': no such target '//src:nonexistent' -ERROR: no such target '//src:nonexistent': target 'nonexistent' not declared INFO: Elapsed time: 0.142s INFO: 0 processes. ERROR: Build did NOT complete successfully"; - let result = brun("", stderr); + let result = filter_bazel_run(output); - assert!( - result.contains("bazel build:") && result.contains("warning"), - "unexpected output:\n{}", - result - ); - assert!(result.contains("ERROR: Skipping")); - assert!(result.contains("ERROR: no such target")); - assert!(!result.contains("Build did NOT complete successfully")); - // No binary output - assert!(!result.contains("Running command line")); - } + assert!(result.contains("bazel run: 1 error")); + assert!(result.contains("ERROR: Skipping")); + assert!(!result.contains("WARNING:")); + assert!(!result.contains("Build did NOT complete successfully")); + } - #[test] - fn test_filter_bazel_run_build_error_no_warnings() { - let stderr = "\ -Loading: + #[test] + /// Test a failed `bazel run` with multiple errors. + fn test_filter_with_multiple_errors() { + let output = "\ ERROR: Skipping '//src:nonexistent': no such target '//src:nonexistent' +ERROR: no such target '//src:nonexistent': target 'nonexistent' not declared INFO: Elapsed time: 0.142s INFO: 0 processes. ERROR: Build did NOT complete successfully"; - let result = brun("", stderr); + let result = filter_bazel_run(output); - assert!(result.contains("bazel build: 1 error, 0 warnings")); - assert!(result.contains("ERROR: Skipping")); - assert!(!result.contains("WARNING:")); - assert!(!result.contains("Build did NOT complete successfully")); - } + assert!(result.contains("bazel run: 2 errors")); + } - #[test] - fn test_filter_bazel_run_binary_stderr() { - let stderr = "\ -INFO: Analyzed target //src:app (0 packages loaded). -INFO: Found 1 target... -INFO: Build completed successfully, 50 total actions -INFO: Running command line: bazel-bin/src/app -Error: could not connect to database -Stack trace: - at main.cc:42 - at db.cc:100"; - let result = brun("", stderr); - - // Clean build — no build summary - assert!(!result.contains("bazel build")); - assert!(result.contains("Error: could not connect to database")); - assert!(result.contains("Stack trace:")); - assert!(result.contains("at main.cc:42")); - } - - #[test] - fn test_filter_bazel_run_no_sentinel() { - // No sentinel = build-only, no binary ran (e.g. build phase completed but no run) - let stderr = "\ + #[test] + /// Test a failed `bazel run` with multiple errors and warnings. + fn test_filter_with_multiple_errors_and_warnings() { + let output = "\ +WARNING: Target pattern parsing failed. +ERROR: Skipping '//src:nonexistent': no such target '//src:nonexistent' +ERROR: no such target '//src:nonexistent': target 'nonexistent' not declared +WARNING: File not found: /home/user/foo/bar.txt +INFO: Elapsed time: 0.142s +INFO: 0 processes. +ERROR: Build did NOT complete successfully"; + let result = filter_bazel_run(output); + + assert!( + result.contains("bazel run:") + && result.contains("error") + && result.contains("warning"), + "unexpected output:\n{}", + result + ); + assert!(result.contains("ERROR: Skipping")); + assert!(result.contains("ERROR: no such target")); + assert!(!result.contains("Build did NOT complete successfully")); + // No binary output + assert!(!result.contains("Running command line")); + } + + #[test] + /// Test a `bazel run` with no sentinel (i.e. nothing was run). + fn test_filter_no_sentinel() { + let output = "\ INFO: Analyzed target //src:app (10 packages loaded). [5 / 10] Compiling something.cc INFO: Found 1 target... Target //src:app up-to-date: - bazel-bin/src/app +bazel-bin/src/app INFO: Build completed successfully, 100 total actions"; - let result = brun("", stderr); + let result = filter_bazel_run(output); + assert!(result.is_empty()); + } - // Falls back to filter_bazel_build behavior - assert!(result.contains("\u{2713} bazel build (100 actions)")); - } + #[test] + fn test_filter_empty() { + let result = filter_bazel_run(""); + assert!(result.is_empty()); + } - #[test] - fn test_filter_bazel_run_strips_build_noise() { - let stderr = "\ -Computing main repo mapping: -Loading: -Loading: 1 packages loaded -Analyzing: target //src:app (6 packages loaded) -DEBUG: /some/debug/info -Note: Some input files use deprecated API. -[0 / 4] [Prepa] BazelWorkspaceStatusAction -[100 / 200] Compiling something.cc -Target //src:app up-to-date: - bazel-bin/src/app -INFO: Elapsed time: 5.00s -INFO: 200 processes: 3 internal, 197 processwrapper-sandbox. -INFO: Build completed successfully, 200 total actions -INFO: Running command line: bazel-bin/src/app"; - let stdout = "output"; - let result = brun(stdout, stderr); - - assert!(!result.contains("Computing main repo")); - assert!(!result.contains("Loading:")); - assert!(!result.contains("Analyzing:")); - assert!(!result.contains("DEBUG:")); - assert!(!result.contains("Note:")); - assert!(!result.contains("[0 / 4]")); - assert!(!result.contains("[100 / 200]")); - assert!(!result.contains("Target //src:app")); - assert!(!result.contains("bazel-bin/src/app")); - assert!(!result.contains("INFO:")); - assert!(result.contains("output")); - } - - #[test] - fn test_filter_bazel_run_empty() { - let result = brun("", ""); - assert_eq!(result, "\u{2713} bazel build (0 actions)"); - } - - #[test] - fn test_filter_bazel_run_token_savings() { - let stderr = "\ + #[test] + fn test_filter_token_savings() { + let original = "\ Computing main repo mapping: Loading: Loading: 0 packages loaded @@ -2776,133 +2887,104 @@ INFO: Analyzed target //src:my_binary (563 packages loaded, 24852 targets config [4,976 / 4,978] Executing genrule //src:package-zip; 1s processwrapper-sandbox INFO: Found 1 target... Target //src:my_binary up-to-date: - bazel-bin/src/my_binary +bazel-bin/src/my_binary INFO: Elapsed time: 54.859s, Critical Path: 49.98s INFO: 2391 processes: 3 internal, 1537 processwrapper-sandbox, 881 worker. INFO: Build completed successfully, 2391 total actions INFO: Running command line: bazel-bin/src/my_binary Hello World"; - let input_tokens = count_tokens(stderr); - let result = brun("", stderr); - let output_tokens = count_tokens(&result); - - let savings = 100.0 - (output_tokens as f64 / input_tokens as f64 * 100.0); - assert!( - savings >= 60.0, - "Bazel run filter: expected ≥60% savings, got {:.1}% ({} → {} tokens)", - savings, - input_tokens, - output_tokens - ); - } + // Filtered output when using `BAZEL_EXTRA_FLAGS` + // + // The token savings computation assumes the agent does *not* + // pass the `BAZEL_EXTRA_FLAGS` when running `bazel run` + // directly. + let prefiltered = "\ +INFO: Analyzed target //src:my_binary (563 packages loaded, 24852 targets configured). +INFO: Found 1 target... +Target //src:my_binary up-to-date: +bazel-bin/src/my_binary +INFO: Elapsed time: 54.859s, Critical Path: 49.98s +INFO: 2391 processes: 3 internal, 1537 processwrapper-sandbox, 881 worker. +INFO: Build completed successfully, 2391 total actions +INFO: Running command line: bazel-bin/src/my_binary +Hello World"; + + let input_tokens = count_tokens(original); + let result = filter_bazel_run(prefiltered); + let output_tokens = count_tokens(&result); + + let savings = 100.0 - (output_tokens as f64 / input_tokens as f64 * 100.0); + assert!( + savings >= 60.0, + "Bazel run filter: expected ≥60% savings, got {:.1}% ({} → {} tokens)", + savings, + input_tokens, + output_tokens + ); + } - #[test] - fn test_filter_bazel_run_timestamp_sentinel() { - let stderr = "\ -(10:23:45) INFO: Analyzed target //src:app (10 packages loaded). -(10:23:46) INFO: Found 1 target... -(10:23:47) INFO: Build completed successfully, 50 total actions -(10:23:48) INFO: Running command line: bazel-bin/src/app -binary output on stderr"; - let stdout = "binary output on stdout"; - let result = brun(stdout, stderr); - - // Clean build — no build summary - assert!(!result.contains("bazel build")); - assert!(result.contains("binary output on stdout")); - assert!(result.contains("binary output on stderr")); - // Sentinel itself should not appear - assert!(!result.contains("Running command line")); - } - - #[test] - fn test_filter_bazel_run_real_world_output() { - // Realistic output from `bazel run` with timestamped lines, env-prefixed - // sentinel, and trailing INFO after the sentinel - let stderr = "\ -(17:17:06) WARNING: some build config deprecation warning -(17:17:06) INFO: Current date is 2026-03-02 -(17:17:06) Computing main repo mapping: -(17:17:06) Loading: -(17:17:06) Loading: 0 packages loaded -(17:17:06) Analyzing: target //src/tools/my_tool:my_tool (0 packages loaded, 0 targets configured) -[0 / 1] checking cached actions -(17:17:06) INFO: Analyzed target //src/tools/my_tool:my_tool (0 packages loaded, 0 targets configured). -(17:17:06) INFO: Found 1 target... -Target //src/tools/my_tool:my_tool up-to-date: - bazel-bin/src/tools/my_tool/my_tool -(17:17:06) INFO: Elapsed time: 0.518s, Critical Path: 0.09s -(17:17:06) INFO: 1 process: 3 action cache hit, 1 internal. -(17:17:06) INFO: Build completed successfully, 1 total action -(17:17:06) INFO: -(17:17:06) INFO: Running command line: env FOO=1 BAR=/tmp/cache bazel-bin/src/tools/my_tool/my_tool -(17:17:06) INFO: Some trailing info line"; - let stdout = "Processing input...\nDone."; - let args: Vec = vec![ - "//src/tools/my_tool".into(), - "--".into(), - "\"some-arg\"".into(), - ]; - let result = brun_with_args(stdout, stderr, &args); - - // WARNING stripped — clean build, no build section - assert!(!result.contains("WARNING:")); - assert!(!result.contains("bazel build")); - // Binary output only - assert!(result.contains("Processing input...")); - assert!(result.contains("Done.")); - // Pre-sentinel build noise stripped - assert!(!result.contains("Computing main repo")); - assert!(!result.contains("Loading:")); - assert!(!result.contains("Analyzing:")); - assert!(!result.contains("[0 / 1]")); - // Post-sentinel output is preserved verbatim - assert!(result.contains("INFO: Some trailing info line")); - assert!(!result.contains("Running command line")); - assert!(!result.contains("FOO=1")); - } - - #[test] - fn test_filter_bazel_run_post_sentinel_prefixed_lines_preserved() { - // INFO/WARNING/DEBUG after sentinel are binary stderr and must be preserved. - let stderr = "\ + #[test] + // Test that a successful `bazel run` preserves `bazel build` like + // messages (e..g start with INFO/WARNING/DEBUG) after the run + // sentinel. + fn test_filter_preserves_messages_after_sentinel() { + let output = "\ INFO: Build completed successfully, 10 total actions INFO: Running command line: bazel-bin/app INFO: Some trailing info line WARNING: App warning DEBUG: App debug -INFO: Another trailing info line -actual binary error output"; - let result = brun("binary stdout", stderr); - - assert!(result.contains("binary stdout")); - assert!(result.contains("actual binary error output")); - assert!(result.contains("INFO: Some trailing info line")); - assert!(result.contains("WARNING: App warning")); - assert!(result.contains("DEBUG: App debug")); - assert!(result.contains("INFO: Another trailing info line")); - } +ERROR: App error +"; + let result = filter_bazel_run(output); + + assert!(result.contains("INFO: Some trailing info line")); + assert!(result.contains("WARNING: App warning")); + assert!(result.contains("DEBUG: App debug")); + assert!(result.contains("ERROR: App error")); + } - #[test] - fn test_filter_bazel_run_preserves_trailing_newline() { - let stderr = "\ + #[test] + /// Test that a successful `bazel run` preserves all newlines. + fn test_filter_preserves_newlines() { + let output = "\ INFO: Build completed successfully, 10 total actions -INFO: Running command line: bazel-bin/app"; - let result = brun("line1\nline2\n", stderr); +INFO: Running command line: bazel-bin/app +line1 - assert!(result.ends_with('\n')); - assert!(result.contains("line1\nline2\n")); - } +line2 - #[test] - fn test_filter_bazel_run_preserves_leading_whitespace() { - let stderr = "\ -INFO: Build completed successfully, 10 total actions -INFO: Running command line: bazel-bin/app"; - let result = brun(" indented\n\tTabbed\n", stderr); +"; + let result = filter_bazel_run(output); + assert_eq!(result, "line1\n\nline2\n\n"); + } - assert!(result.contains(" indented")); - assert!(result.contains("\tTabbed")); + #[test] + fn test_filter_preserves_leading_whitespace() { + let output = concat!( + "INFO: Build completed successfully, 10 total actions\n", + "INFO: Running command line: bazel-bin/app\n", + " indented\n", + "\tTabbed\n", + ); + let expected = concat!(" indented\n", "\tTabbed\n",); + let actual = filter_bazel_run(output); + + assert_eq!(expected, actual); + } + + #[test] + fn test_filter_preserves_trailing_whitespace() { + let output = concat!( + "INFO: Build completed successfully, 10 total actions\n", + "INFO: Running command line: bazel-bin/app\n", + " hello! oops I left some trailing whitespace \n", + ); + let expected = " hello! oops I left some trailing whitespace \n"; + let actual = filter_bazel_run(output); + + assert_eq!(expected, actual); + } } } From b0160975f88a651a0be6e7599152f4a04ac420b4 Mon Sep 17 00:00:00 2001 From: cmolder <28611108+cmolder@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:36:14 -0800 Subject: [PATCH 8/9] Add bazel commands to rtk discover registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register bazel build, test, run, and query in the discover command registry so `rtk discover` flags missed bazel usage in Claude Code sessions. Savings percentages derived empirically from real bazel runs on the bazel repo itself (~/GitHub/bazel): - build: 97% (169 → 5 tokens) - test: 82% (119 → 21 tokens) - run: 97% (build-phase only, binary output is passthrough) - query: 98% (3043 → 46 tokens) Add dedicated "Bazel" category to category_avg_tokens() with conservative estimates (200/100/200) since small incremental builds and narrow queries are the typical developer workflow. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/discover/registry.rs | 62 +++++++++++++++++++++++++++++++++++++--- src/discover/rules.rs | 16 +++++++++++ 2 files changed, 74 insertions(+), 4 deletions(-) diff --git a/src/discover/registry.rs b/src/discover/registry.rs index 2661708c..809ae722 100644 --- a/src/discover/registry.rs +++ b/src/discover/registry.rs @@ -31,6 +31,11 @@ pub fn category_avg_tokens(category: &str, subcmd: &str) -> usize { }, "Tests" => 800, "Files" => 100, + "Bazel" => match subcmd { + "query" => 200, + "test" => 100, + _ => 200, + }, "Build" => 300, "Infra" => 120, "Network" => 150, @@ -796,6 +801,58 @@ mod tests { assert_eq!(split_command_chain(cmd), vec![cmd]); } + #[test] + fn test_classify_bazel_build() { + assert_eq!( + classify_command("bazel build //src:app"), + Classification::Supported { + rtk_equivalent: "rtk bazel", + category: "Bazel", + estimated_savings_pct: 97.0, + status: RtkStatus::Existing, + } + ); + } + + #[test] + fn test_classify_bazel_test() { + assert_eq!( + classify_command("bazel test //src:test"), + Classification::Supported { + rtk_equivalent: "rtk bazel", + category: "Bazel", + estimated_savings_pct: 82.0, + status: RtkStatus::Existing, + } + ); + } + + #[test] + fn test_classify_bazel_query() { + assert_eq!( + classify_command("bazel query 'deps(//...)'"), + Classification::Supported { + rtk_equivalent: "rtk bazel", + category: "Bazel", + estimated_savings_pct: 98.0, + status: RtkStatus::Existing, + } + ); + } + + #[test] + fn test_classify_bazel_run() { + assert_eq!( + classify_command("bazel run //src:bin"), + Classification::Supported { + rtk_equivalent: "rtk bazel", + category: "Bazel", + estimated_savings_pct: 97.0, + status: RtkStatus::Existing, + } + ); + } + #[test] fn test_classify_mypy() { assert_eq!( @@ -1779,10 +1836,7 @@ mod tests { #[test] fn test_rewrite_gh_json_skipped() { - assert_eq!( - rewrite_command("gh pr list --json number,title", &[]), - None - ); + assert_eq!(rewrite_command("gh pr list --json number,title", &[]), None); } #[test] diff --git a/src/discover/rules.rs b/src/discover/rules.rs index d6b11c1e..b7269562 100644 --- a/src/discover/rules.rs +++ b/src/discover/rules.rs @@ -48,6 +48,8 @@ pub const PATTERNS: &[&str] = &[ r"^aws\s+", // PostgreSQL r"^psql(\s|$)", + // Bazel + r"^bazel\s+(build|test|run|query)", ]; pub const RULES: &[RtkRule] = &[ @@ -317,6 +319,20 @@ pub const RULES: &[RtkRule] = &[ subcmd_savings: &[], subcmd_status: &[], }, + // Bazel + RtkRule { + rtk_cmd: "rtk bazel", + rewrite_prefixes: &["bazel"], + category: "Bazel", + savings_pct: 90.0, + subcmd_savings: &[ + ("build", 97.0), + ("test", 82.0), + ("run", 97.0), + ("query", 98.0), + ], + subcmd_status: &[], + }, ]; /// Commands to ignore (shell builtins, trivial, already rtk). From 931a75f5b1e79db925fd947161f9a41f930aaec8 Mon Sep 17 00:00:00 2001 From: cmolder <28611108+cmolder@users.noreply.github.com> Date: Thu, 5 Mar 2026 17:56:44 -0800 Subject: [PATCH 9/9] Apply reviewer feedback 1. Remove `assert!` and `expect` when parsing action count. By default we only use the first action count found. Also add debug prints for weird cases. 2. Use `resolved_command` in #269 3. Return `Result` from all filter functions, and fall back to printing raw output if there is an error Other changes: 1. Add `run_bazel_filtered` generic function to match Cargo and reduce code reuse 2. Merge stdout and stderr in `filter_bazel_query` (should probably refactor `bazel query` some more) --- src/bazel_cmd.rs | 655 ++++++++++++++++++++--------------------------- src/main.rs | 2 +- 2 files changed, 276 insertions(+), 381 deletions(-) diff --git a/src/bazel_cmd.rs b/src/bazel_cmd.rs index 842ee725..7b75b451 100644 --- a/src/bazel_cmd.rs +++ b/src/bazel_cmd.rs @@ -1,11 +1,11 @@ use crate::tracking; +use crate::utils::resolved_command; use anyhow::{Context, Result}; use lazy_static::lazy_static; use regex::Regex; use std::collections::{BTreeMap, HashMap, HashSet}; use std::ffi::OsString; use std::fmt; -use std::process::Command; use std::str::FromStr; /**********************************************************************/ @@ -120,6 +120,83 @@ impl fmt::Display for Limit { } } +/// Run a bazel subcommand and filtering the output. +/// +/// # Arguments +/// +/// * `subcommand` - Bazel subcommand to run +/// * `args` - Subcommand arguments +/// * `verbose` - Verbosity level +/// * `filter_fn` - Function to filter the output +/// +/// # Returns +/// +/// Result of the operation +/// +fn run_bazel_filtered(subcommand: &str, args: &[String], verbose: u8, filter_fn: F) -> Result<()> +where + F: Fn(&str) -> Result, +{ + let timer = tracking::TimedExecution::start(); + + let mut cmd = resolved_command("bazel"); + cmd.arg(subcommand); + cmd.args(BAZEL_EXTRA_FLAGS); + for arg in args { + cmd.arg(arg); + } + + if verbose > 0 { + eprintln!("Running: bazel build {}", args.join(" ")); + } + + let output = cmd + .output() + .context("Failed to run bazel build. Is Bazel 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 }); + + match filter_fn(&raw) { + Ok(filtered) => { + // Print filtered output + match crate::tee::tee_and_hint(&raw, "bazel_build", exit_code) { + Some(hint) => println!("{}\n{}", filtered, hint), + None => println!("{}", filtered), + }; + + // Track filtering + timer.track( + &format!("bazel {} {}", subcommand, args.join(" ")), + &format!("rtk bazel {} {}", subcommand, args.join(" ")), + &raw, + &filtered, + ); + } + Err(e) => { + // Print raw output + #[cfg(debug_assertions)] + eprintln!( + "rtk: filtering bazel {} failed, showing raw output: {}", + subcommand, e + ); + + println!("{}", raw); + } + } + + if !output.status.success() { + std::process::exit(exit_code); + } + + Ok(()) +} + /**********************************************************************/ /* bazel build */ /**********************************************************************/ @@ -220,14 +297,45 @@ impl BazelBuildState { let trimmed = line.trim(); // Bazel action count - if let Some(ac) = ACTION_COUNT.captures(trimmed) { - assert!(self.action_count.is_none(), "action count already set"); - let ac = ac[1].parse::().expect("expected a number"); - self.action_count = Some(ac); - } + if let Some(captures) = ACTION_COUNT.captures(trimmed) { + // Incremental builds may emit "total actions" multiple times. + // For simplicity, only remember the first action count seen. + if self.action_count.is_some() { + #[cfg(debug_assertions)] + eprintln!("rtk: Duplicate action count in line '{}'", trimmed); + return; + } + match captures.get(1).map(|capture| capture.as_str().parse()) { + // Successfully parsed action count + // (`.parse()` returned `Ok`) + Some(Ok(action_count)) => { + self.action_count = Some(action_count); + } + // Failed to parse action count + // (`.parse()` returned `Err`) + Some(Err(e)) => { + #[cfg(debug_assertions)] + eprintln!( + "rtk: Failed to parse action count from capture '{}' in line '{}': {}", + &captures.get(1).unwrap().as_str(), + trimmed, + e + ); + } + // Not enough capture groups + // (`.get(1)` returned `None`) + None => { + #[cfg(debug_assertions)] + eprintln!( + "rtk: Not enough captures for action count in line '{}'", + trimmed + ); + } + } + } // Bazel error - if trimmed.starts_with("ERROR:") { + else if trimmed.starts_with("ERROR:") { // Flush any in-progress compiler diagnostic block. if self.in_diagnostic_block() { self.consume_diagnostic(); @@ -236,16 +344,14 @@ impl BazelBuildState { // Skip the summary "Build did NOT complete successfully" — we show our own header if trimmed.contains("Build did NOT complete successfully") { return; - // error_count = error_count.max(1); // Ensure we show error header } // Add the Bazel error self.errors.push(trimmed.to_string()); return; } - // Bazel warning - if trimmed.starts_with("WARNING:") { + else if trimmed.starts_with("WARNING:") { // Flush any in-progress compiler diagnostic block. if self.in_diagnostic_block() { self.consume_diagnostic(); @@ -255,9 +361,8 @@ impl BazelBuildState { self.warnings.push(trimmed.to_string()); return; } - // Start of diagnostic block - if trimmed.starts_with("warning:") + else if trimmed.starts_with("warning:") || trimmed.starts_with("error:") || trimmed.contains(": warning:") || trimmed.contains(": error:") @@ -272,9 +377,8 @@ impl BazelBuildState { self.diagnostic.is_error = trimmed.contains(": error:"); return; } - // Currently inside diagnostic block - if self.in_diagnostic_block() { + else if self.in_diagnostic_block() { if trimmed.is_empty() { // End of diagnostic block self.consume_diagnostic(); @@ -319,7 +423,7 @@ impl BazelBuildState { /// * Status lines (e.g. `Loading ...`) /// * Timestamps /// -pub fn filter_bazel_build(output: &str) -> String { +pub fn filter_bazel_build(output: &str) -> Result { let mut state = BazelBuildState::default(); for line in output.lines() { state.digest_line(line); @@ -373,7 +477,7 @@ pub fn filter_bazel_build(output: &str) -> String { // If succesful, return only the summary line. if state.success() { - return build_summary; + return Ok(build_summary); } // Otherwise, include a summary of the warnings and errors. @@ -401,7 +505,7 @@ pub fn filter_bazel_build(output: &str) -> String { )); } - result.trim().to_string() + Ok(result.trim().to_string()) } /// Run `bazel build` while filtering the output. @@ -416,52 +520,7 @@ pub fn filter_bazel_build(output: &str) -> String { /// Result of the operation /// pub fn run_build(args: &[String], verbose: u8) -> Result<()> { - let timer = tracking::TimedExecution::start(); - - let mut cmd = Command::new("bazel"); - cmd.arg("build"); - cmd.args(BAZEL_EXTRA_FLAGS); - - for arg in args { - cmd.arg(arg); - } - - if verbose > 0 { - eprintln!("Running: bazel build {}", args.join(" ")); - } - - let output = cmd - .output() - .context("Failed to run bazel build. Is Bazel 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_bazel_build(&raw); - - if let Some(hint) = crate::tee::tee_and_hint(&raw, "bazel_build", exit_code) { - println!("{}\n{}", filtered, hint); - } else { - println!("{}", filtered); - } - - timer.track( - &format!("bazel build {}", args.join(" ")), - &format!("rtk bazel build {}", args.join(" ")), - &raw, - &filtered, - ); - - if !output.status.success() { - std::process::exit(exit_code); - } - - Ok(()) + run_bazel_filtered("build", args, verbose, filter_bazel_build) } /**********************************************************************/ @@ -615,11 +674,11 @@ impl BazelTestState { /// /// # Arguments /// -/// * `output` - output from `bazel test` +/// * `output` - Output from `bazel test` /// /// # Returns /// -/// The filtered `bazel test` output +/// The filtered output /// /// # Notes /// @@ -628,7 +687,7 @@ impl BazelTestState { /// On all-pass, returns a one-liner. On failure, shows FAIL blocks and /// inline test output while stripping surrounding noise. /// -pub fn filter_bazel_test(output: &str) -> String { +pub fn filter_bazel_test(output: &str) -> Result { let mut state = BazelTestState::default(); for line in output.lines() { state.digest_line(line); @@ -641,7 +700,7 @@ pub fn filter_bazel_test(output: &str) -> String { if state.passed == 0 && state.failed == 0 && !state.error_lines.is_empty() { let mut result = String::from("bazel test: build failed\n"); result.push_str("═══════════════════════════════════════\n"); - for err in state.error_lines.iter().take(15) { + for err in state.error_lines.iter().take(MAX_BUILD_ISSUES) { result.push_str(err); result.push('\n'); } @@ -651,15 +710,15 @@ pub fn filter_bazel_test(output: &str) -> String { state.error_lines.len() - 15 )); } - return result.trim().to_string(); + return Ok(result.trim().to_string()); } // All pass: one-liner. if state.failed == 0 { - return format!( + return Ok(format!( "\u{2713} bazel test: {} passed, 0 failed ({}s)", state.passed, elapsed_str - ); + )); } // Has failures: show details. @@ -721,11 +780,14 @@ pub fn filter_bazel_test(output: &str) -> String { + state.inline_output_blocks.len() + state.failed_result_lines.len() + state.error_lines.len(); - if total_blocks > 15 { - result.push_str(&format!("\n... +{} more blocks\n", total_blocks - 15)); + if total_blocks > MAX_BUILD_ISSUES { + result.push_str(&format!( + "\n... +{} more blocks\n", + total_blocks - MAX_BUILD_ISSUES + )); } - result.trim().to_string() + Ok(result.trim().to_string()) } /// Run `bazel test` while filtering the output. @@ -740,51 +802,7 @@ pub fn filter_bazel_test(output: &str) -> String { /// Result of the operation /// pub fn run_test(args: &[String], verbose: u8) -> Result<()> { - let timer = tracking::TimedExecution::start(); - - let mut cmd = Command::new("bazel"); - cmd.arg("test"); - - for arg in args { - cmd.arg(arg); - } - - if verbose > 0 { - eprintln!("Running: bazel test {}", args.join(" ")); - } - - let output = cmd - .output() - .context("Failed to run bazel test. Is Bazel 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_bazel_test(&raw); - - if let Some(hint) = crate::tee::tee_and_hint(&raw, "bazel_test", exit_code) { - println!("{}\n{}", filtered, hint); - } else { - println!("{}", filtered); - } - - timer.track( - &format!("bazel test {}", args.join(" ")), - &format!("rtk bazel test {}", args.join(" ")), - &raw, - &filtered, - ); - - if !output.status.success() { - std::process::exit(exit_code); - } - - Ok(()) + run_bazel_filtered("test", args, verbose, filter_bazel_test) } /**********************************************************************/ @@ -802,7 +820,7 @@ enum BazelRunStage { /// /// # Arguments /// -/// * `output` - output from `bazel run` +/// * `output` - Output from `bazel run` /// /// # Returns /// @@ -819,7 +837,7 @@ enum BazelRunStage { /// This filter splits stderr at the sentinel, applies `filter_bazel_build` /// to the build phase, then appends the binary's output verbatim. /// -pub fn filter_bazel_run(output: &str) -> String { +pub fn filter_bazel_run(output: &str) -> Result { let mut current_stage = BazelRunStage::Build; let mut build_state = BazelBuildState::default(); let mut run_lines: Vec = Vec::new(); @@ -852,7 +870,7 @@ pub fn filter_bazel_run(output: &str) -> String { if !build_state.has_errors() { // `split_inclusive('\n')` preserves newline delimiters in each segment, // so concatenate directly to avoid injecting extra blank lines. - return run_lines.concat(); + return Ok(run_lines.concat()); } // Build the summary line. @@ -921,10 +939,10 @@ pub fn filter_bazel_run(output: &str) -> String { )); } - result.trim().to_string() + Ok(result.trim().to_string()) } -/// Run `bazel run` while filtering the build output. +/// Run `bazel run` while filtering the output. /// /// # Arguments /// @@ -936,52 +954,7 @@ pub fn filter_bazel_run(output: &str) -> String { /// Result of the operation /// pub fn run_run(args: &[String], verbose: u8) -> Result<()> { - let timer = tracking::TimedExecution::start(); - - let mut cmd = Command::new("bazel"); - cmd.arg("run"); - cmd.args(BAZEL_EXTRA_FLAGS); - - for arg in args { - cmd.arg(arg); - } - - if verbose > 0 { - eprintln!("Running: bazel run {}", args.join(" ")); - } - - let output = cmd - .output() - .context("Failed to run bazel run. Is Bazel 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_bazel_run(&raw); - - if let Some(hint) = crate::tee::tee_and_hint(&raw, "bazel_run", exit_code) { - println!("{}\n{}", filtered, hint); - } else { - println!("{}", filtered); - } - - timer.track( - &format!("bazel run {}", args.join(" ")), - &format!("rtk bazel run {}", args.join(" ")), - &raw, - &filtered, - ); - - if !output.status.success() { - std::process::exit(exit_code); - } - - Ok(()) + run_bazel_filtered("run", args, verbose, filter_bazel_run) } /**********************************************************************/ @@ -1380,23 +1353,22 @@ fn collect_section_nodes( } } -pub fn filter_bazel_query(stdout: &str, stderr: &str, depth: usize, width: usize) -> String { +/// Filter `bazel query` output. +/// +/// # Arguments +/// +/// * `output` - Output from `bazel query` +/// * `depth` - Maximum depth of the package tree to show +/// * `width` - Maximum number of items to show per package +/// +/// # Returns +/// +/// The filtered output +/// +pub fn filter_bazel_query(output: &str, depth: usize, width: usize) -> Result { let mut result = String::new(); let mut has_error_lines = false; - // Collect ERROR lines from stderr - for line in stderr.lines() { - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } - if trimmed.starts_with("ERROR:") { - has_error_lines = true; - result.push_str(trimmed); - result.push('\n'); - } - } - // Group targets by output-derived roots: // - local roots: "//" and "//level0" // - external roots: "@repo" @@ -1404,15 +1376,21 @@ pub fn filter_bazel_query(stdout: &str, stderr: &str, depth: usize, width: usize let mut external_sections: BTreeMap>> = BTreeMap::new(); let mut section_order: Vec = Vec::new(); let mut seen_sections: HashSet = HashSet::new(); - let mut non_target_lines: Vec = Vec::new(); - for line in stdout.lines() { + for line in output.lines() { let trimmed = line.trim(); + if trimmed.is_empty() { continue; } - - if let Some(caps) = TARGET_LINE.captures(trimmed) { + // Bazel error + else if trimmed.starts_with("ERROR:") { + has_error_lines = true; + result.push_str(trimmed); + result.push('\n'); + } + // Bazel target + else if let Some(caps) = TARGET_LINE.captures(trimmed) { let package = caps[1].to_string(); let target = caps[2].to_string(); @@ -1462,8 +1440,6 @@ pub fn filter_bazel_query(stdout: &str, stderr: &str, depth: usize, width: usize section_order.push(section); } } - } else { - non_target_lines.push(trimmed.to_string()); } } @@ -1546,13 +1522,7 @@ pub fn filter_bazel_query(stdout: &str, stderr: &str, depth: usize, width: usize } } - // Output non-target lines - for line in &non_target_lines { - result.push_str(line); - result.push('\n'); - } - - result.trim_end().to_string() + Ok(result.trim_end().to_string()) } /// Run `bazel query` while filtering the output. @@ -1569,62 +1539,16 @@ pub fn filter_bazel_query(stdout: &str, stderr: &str, depth: usize, width: usize /// Result of the operation /// pub fn run_query(args: &[String], depth: Limit, width: Limit, verbose: u8) -> Result<()> { - let timer = tracking::TimedExecution::start(); - - let mut cmd = Command::new("bazel"); - cmd.arg("query"); - - for arg in args { - cmd.arg(arg); - } - - if verbose > 0 { - eprintln!("Running: bazel query {}", args.join(" ")); - } - - let output = cmd - .output() - .context("Failed to run bazel query. Is Bazel 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_bazel_query(&stdout, &stderr, depth.value(), width.value()); - - if output.status.success() { - if let Some(hint) = crate::tee::tee_and_hint(&raw, "bazel_query", exit_code) { - println!("{}\n{}", filtered, hint); - } else { - println!("{}", filtered); - } - } else { - println!("{}", filtered); - } - - timer.track( - &format!("bazel query {}", args.join(" ")), - &format!("rtk bazel query {}", args.join(" ")), - &raw, - &filtered, - ); - - if !output.status.success() { - std::process::exit(exit_code); - } - - Ok(()) + run_bazel_filtered("query", args, verbose, |output| { + filter_bazel_query(output, depth.value(), width.value()) + }) } /**********************************************************************/ /* Other bazel subcommands */ /**********************************************************************/ -/// Run other `bazel` subcommands not handled by rtk. +/// Run a unsupported `bazel` subcommand by passing it through directly. /// /// # Arguments /// @@ -1635,45 +1559,30 @@ pub fn run_query(args: &[String], depth: Limit, width: Limit, verbose: u8) -> Re /// /// Result of the operation /// -pub fn run_other(args: &[OsString], verbose: u8) -> Result<()> { +pub fn run_passthrough(args: &[OsString], verbose: u8) -> Result<()> { if args.is_empty() { anyhow::bail!("bazel: no subcommand specified"); } - let timer = tracking::TimedExecution::start(); - - let subcommand = args[0].to_string_lossy(); - let mut cmd = Command::new("bazel"); - cmd.arg(&*subcommand); - - for arg in &args[1..] { - cmd.arg(arg); - } - if verbose > 0 { - eprintln!("Running: bazel {} ...", subcommand); + eprintln!("bazel passthrough: {:?}", args); } - let output = cmd - .output() - .with_context(|| format!("Failed to run bazel {}", subcommand))?; - - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - let raw = format!("{}\n{}", stdout, stderr); + let timer = tracking::TimedExecution::start(); - print!("{}", stdout); - eprint!("{}", stderr); + let status = resolved_command("bazel") + .args(args) + .status() + .context("Failed to run bazel")?; - timer.track( - &format!("bazel {}", subcommand), - &format!("rtk bazel {}", subcommand), - &raw, - &raw, // No filtering for unsupported commands + let args_str = tracking::args_display(args); + timer.track_passthrough( + &format!("bazel {}", args_str), + &format!("rtk bazel {} (passthrough)", args_str), ); - if !output.status.success() { - std::process::exit(output.status.code().unwrap_or(1)); + if !status.success() { + std::process::exit(status.code().unwrap_or(1)); } Ok(()) @@ -1709,7 +1618,7 @@ INFO: Elapsed time: 0.453s, Critical Path: 0.00s INFO: 1 process: 1 internal. INFO: Build completed successfully, 1 total action "; - let result = filter_bazel_build(output); + let result = filter_bazel_build(output).unwrap(); assert_eq!(result, "✓ bazel build (1 action)"); } @@ -1724,7 +1633,7 @@ bazel-bin/src/bazel-dev INFO: Elapsed time: 54.859s, Critical Path: 49.98s INFO: 2391 processes: 3 internal, 1537 processwrapper-sandbox, 881 worker. INFO: Build completed successfully, 2391 total actions"; - let result = filter_bazel_build(output); + let result = filter_bazel_build(output).unwrap(); assert_eq!(result, "✓ bazel build (2391 actions)"); } @@ -1738,7 +1647,7 @@ Target //src:bazel-dev up-to-date: bazel-bin/src/bazel-dev INFO: Elapsed time: 54.859s, Critical Path: 49.98s "; - let result = filter_bazel_build(output); + let result = filter_bazel_build(output).unwrap(); assert_eq!(result, "✓ bazel build"); } @@ -1759,7 +1668,7 @@ Target //src:bazel-dev up-to-date: bazel-bin/src/bazel-dev INFO: Elapsed time: 54.859s, Critical Path: 49.98s INFO: Build completed successfully, 4978 total actions"; - let result = filter_bazel_build(output); + let result = filter_bazel_build(output).unwrap(); eprintln!("[DEBUG] Result is:\n{}", result); @@ -1785,7 +1694,7 @@ ERROR: no such target '//src:bazel-dev-NONEXISTENT': target 'bazel-dev-NONEXISTE INFO: Elapsed time: 0.142s INFO: 0 processes. ERROR: Build did NOT complete successfully"; - let result = filter_bazel_build(output); + let result = filter_bazel_build(output).unwrap(); // Build summary assert!(result.contains("bazel build: 2 errors")); @@ -1815,7 +1724,7 @@ ERROR: no such target '//src:bazel-dev-NONEXISTENT': target 'bazel-dev-NONEXISTE INFO: Elapsed time: 0.142s INFO: 0 processes. ERROR: Build did NOT complete successfully"; - let result = filter_bazel_build(output); + let result = filter_bazel_build(output).unwrap(); // Build summary assert!(result.contains("bazel build: 2 errors, 1 warning")); @@ -1849,7 +1758,7 @@ bazel-out/k8-fastbuild/bin/src/main/protobuf/failure_details.pb.h:1690:3: note: | ^~~~~~~~~ INFO: Build completed successfully, 200 total actions"; - let result = filter_bazel_build(output); + let result = filter_bazel_build(output).unwrap(); // Should keep the compiler warning block assert!(result.contains("warning:")); @@ -1874,7 +1783,7 @@ warning: field `value` is never read = note: `#[warn(dead_code)]` on by default INFO: Build completed successfully, 42 total actions"; - let result = filter_bazel_build(output); + let result = filter_bazel_build(output).unwrap(); assert!(result.contains("warning: field `value` is never read")); assert!(result.contains("src/lib.rs:12:5")); @@ -1891,7 +1800,7 @@ src/main.c:14:9: warning: unused variable 'tmp' [-Wunused-variable] | ^~~ INFO: Build completed successfully, 7 total actions"; - let result = filter_bazel_build(output); + let result = filter_bazel_build(output).unwrap(); assert!(result.contains("warning: unused variable 'tmp'")); assert!(result.contains("src/main.c:14:9")); @@ -1909,7 +1818,7 @@ src/main.cc:21:7: warning: unused variable 'counter' [-Wunused-variable] 1 warning generated. INFO: Build completed successfully, 11 total actions"; - let result = filter_bazel_build(output); + let result = filter_bazel_build(output).unwrap(); assert!(result.contains("warning: unused variable 'counter'")); assert!(result.contains("src/main.cc:21:7")); @@ -1934,7 +1843,7 @@ Note: Recompile with -Xlint:removal for details. Target //src:bazel-dev up-to-date: bazel-bin/src/bazel-dev DEBUG: some debug info"; - let result = filter_bazel_build(stderr); + let result = filter_bazel_build(stderr).unwrap(); assert!(!result.contains("Computing main repo")); assert!(!result.contains("Loading:")); @@ -1949,7 +1858,7 @@ DEBUG: some debug info"; #[test] fn test_filter_empty() { - let result = filter_bazel_build(""); + let result = filter_bazel_build("").unwrap(); assert_eq!(result, "✓ bazel build"); } @@ -1981,7 +1890,7 @@ INFO: 2391 processes: 3 internal, 1537 processwrapper-sandbox, 881 worker. INFO: Build completed successfully, 2391 total actions"; let input_tokens = count_tokens(output); - let result = filter_bazel_build(output); + let result = filter_bazel_build(output).unwrap(); let output_tokens = count_tokens(&result); let savings = 100.0 - (output_tokens as f64 / input_tokens as f64 * 100.0); @@ -2001,7 +1910,7 @@ INFO: Build completed successfully, 2391 total actions"; for i in 0..20 { stderr.push_str(&format!("ERROR: //pkg:target_{}: build failed\n\n", i)); } - let result = filter_bazel_build(&stderr); + let result = filter_bazel_build(&stderr).unwrap(); assert!(result.contains("... +5 more issues")); } @@ -2018,7 +1927,7 @@ src/app.cc:42:10: error: use of undeclared identifier 'foo' ERROR: //src:app failed to build INFO: Build completed, 0 total actions ERROR: Build did NOT complete successfully"; - let result = filter_bazel_build(output); + let result = filter_bazel_build(output).unwrap(); // Should have 2 errors (compiler + bazel ERROR) and 1 warning assert!(result.contains("2 errors")); @@ -2062,16 +1971,16 @@ ERROR: Build did NOT complete successfully"; #[test] fn test_strips_info_warning_noise() { - let stderr = "\ + let output = "\ INFO: Invocation ID: abc-123 INFO: Build options changed WARNING: some warning DEBUG: debug info INFO: plain info line WARNING: plain warning -DEBUG: plain debug"; - let stdout = "//pkg:target"; - let result = filter_bazel_query(stdout, stderr, usize::MAX, usize::MAX); +DEBUG: plain debug +//pkg:target"; + let result = filter_bazel_query(output, usize::MAX, usize::MAX).unwrap(); assert!(!result.contains("Invocation ID")); assert!(!result.contains("Build options changed")); @@ -2085,12 +1994,12 @@ DEBUG: plain debug"; #[test] fn test_keeps_error_lines() { - let stderr = "\ + let output = "\ INFO: Build options changed ERROR: something went wrong -ERROR: another error"; - let stdout = "//pkg:target"; - let result = filter_bazel_query(stdout, stderr, usize::MAX, usize::MAX); +ERROR: another error +//pkg:target"; + let result = filter_bazel_query(output, usize::MAX, usize::MAX).unwrap(); assert!(result.contains("ERROR: something went wrong")); assert!(result.contains("ERROR: another error")); @@ -2099,28 +2008,15 @@ ERROR: another error"; #[test] fn test_empty_output() { - let result = filter_bazel_query("", "", usize::MAX, usize::MAX); + let result = filter_bazel_query("", usize::MAX, usize::MAX).unwrap(); // With default root, header is still produced assert!(result.contains("// (0 targets)")); } - #[test] - fn test_non_target_lines_pass_through() { - let stdout = "\ -//pkg:target_a -some non-target output line -//:root_target"; - let result = filter_bazel_query(stdout, "", usize::MAX, usize::MAX); - - assert!(result.contains("some non-target output line")); - assert!(result.contains("🎯 :target_a")); - assert!(result.contains("🎯 :root_target")); - } - #[test] fn test_single_target_uses_singular() { let stdout = "//my/package:only_target"; - let result = filter_bazel_query(stdout, "", usize::MAX, usize::MAX); + let result = filter_bazel_query(stdout, usize::MAX, usize::MAX).unwrap(); assert!(result.contains("(1 target)")); } @@ -2130,7 +2026,7 @@ some non-target output line //src/lib:a //src/lib:b //tools:c"; - let result = filter_bazel_query(stdout, "", usize::MAX, usize::MAX); + let result = filter_bazel_query(stdout, usize::MAX, usize::MAX).unwrap(); assert!(result.contains("//src/lib (2 targets)")); assert!(result.contains("//tools (1 target)")); @@ -2146,7 +2042,7 @@ some non-target output line //tools/gen:e //tools/gen:f //:root_target"; - let result = filter_bazel_query(stdout, "", 1, usize::MAX); + let result = filter_bazel_query(stdout, 1, usize::MAX).unwrap(); assert!(result.contains("//src (3 targets, 2 packages)")); assert!(result.contains("//tools/gen (3 targets)")); @@ -2162,7 +2058,7 @@ some non-target output line //src/lib/io:c //src/app:d //tools:e"; - let result = filter_bazel_query(stdout, "", 2, usize::MAX); + let result = filter_bazel_query(stdout, 2, usize::MAX).unwrap(); assert!(result.contains("//src/app (1 target)")); assert!(result.contains("//src/lib (3 targets, 2 packages)")); @@ -2175,7 +2071,7 @@ some non-target output line //src/lib/math:a //src/lib/io:b //src/app:c"; - let result = filter_bazel_query(stdout, "", usize::MAX, usize::MAX); + let result = filter_bazel_query(stdout, usize::MAX, usize::MAX).unwrap(); assert!(result.contains("//src/app (1 target)")); assert!(result.contains("//src/lib/io (1 target)")); @@ -2193,7 +2089,7 @@ some non-target output line //examples/cpp:b //examples/go:c //examples/java/sub:d"; - let result = filter_bazel_query(stdout, "", 2, usize::MAX); + let result = filter_bazel_query(stdout, 2, usize::MAX).unwrap(); assert!(result.contains("//examples/cpp (2 targets)")); assert!(result.contains("//examples/go (1 target)")); @@ -2210,7 +2106,7 @@ some non-target output line //root:root_a //root:root_b //root:root_c"; - let result = filter_bazel_query(stdout, "", 1, 5); + let result = filter_bazel_query(stdout, 1, 5).unwrap(); assert!(result.contains("//root (7 targets, 4 packages)")); assert!(result.contains("📦 a (1 target)")); @@ -2230,7 +2126,7 @@ some non-target output line //root/c:t3 //root/d:t4 //root/e:t5"; - let result = filter_bazel_query(stdout, "", 1, 3); + let result = filter_bazel_query(stdout, 1, 3).unwrap(); assert!(result.contains("//root (5 targets, 5 packages)")); assert!(result.contains("📦 a")); @@ -2251,7 +2147,7 @@ some non-target output line //root:x //root:y //root:z"; - let result = filter_bazel_query(stdout, "", 1, 3); + let result = filter_bazel_query(stdout, 1, 3).unwrap(); assert!(result.contains("(+1 more sub-package, 3 more targets)")); } @@ -2263,7 +2159,7 @@ some non-target output line //root/b:t //root/c:t //root/d:t"; - let result = filter_bazel_query(stdout, "", 1, 3); + let result = filter_bazel_query(stdout, 1, 3).unwrap(); assert!(result.contains("(+1 more sub-package)")); assert!(!result.contains("more target")); @@ -2275,7 +2171,7 @@ some non-target output line //:bazel-distfile //:bazel-srcs //src:lib"; - let result = filter_bazel_query(stdout, "", 1, usize::MAX); + let result = filter_bazel_query(stdout, 1, usize::MAX).unwrap(); assert!(result.contains("//src (1 target)")); assert!(result.contains("// (2 targets)")); @@ -2288,7 +2184,7 @@ some non-target output line let stdout = "\ //examples/cpp:a //examples/go:b"; - let result = filter_bazel_query(stdout, "", 2, usize::MAX); + let result = filter_bazel_query(stdout, 2, usize::MAX).unwrap(); assert!(result.contains("//examples/cpp (1 target)")); assert!(result.contains("//examples/go (1 target)")); @@ -2302,7 +2198,7 @@ some non-target output line //src/lib/math/compute:target_c //tools/codegen:foo //tools/codegen:bar"; - let result = filter_bazel_query(stdout, "", usize::MAX, usize::MAX); + let result = filter_bazel_query(stdout, usize::MAX, usize::MAX).unwrap(); // With full depth, targets are at leaf nodes assert!(result.contains("🎯 :target_a")); @@ -2314,14 +2210,13 @@ some non-target output line #[test] fn test_real_bazel_output() { - let stderr = "\ + let output = "\ INFO: Invocation ID: 8e2f4a91-abc1-4def-9012-345678abcdef INFO: Current date is 2026-03-01 WARNING: Build option --config=remote has changed INFO: Repository rule @bazel_tools//tools/jdk:jdk configured INFO: Found 16 targets... -INFO: Elapsed time: 1.234s"; - let stdout = "\ +INFO: Elapsed time: 1.234s //src/app/foo/bar:bar //src/app/foo/bar:bar_test //src/app/foo/bar:bar_lib @@ -2339,7 +2234,7 @@ INFO: Elapsed time: 1.234s"; //src/app/foo/bar:runner //src/app/foo/bar:runner_test"; - let result = filter_bazel_query(stdout, stderr, usize::MAX, usize::MAX); + let result = filter_bazel_query(output, usize::MAX, usize::MAX).unwrap(); // Should strip all INFO/WARNING noise assert!(!result.contains("Invocation ID")); @@ -2353,11 +2248,11 @@ INFO: Elapsed time: 1.234s"; #[test] fn test_filter_multi_root_no_target_loss() { - let stdout = "\ + let output = "\ //src/app:bin //tools/gen:tool //third_party/lib:pkg"; - let result = filter_bazel_query(stdout, "", usize::MAX, usize::MAX); + let result = filter_bazel_query(output, usize::MAX, usize::MAX).unwrap(); assert!(result.contains("//src/app (1 target)")); assert!(result.contains("//tools/gen (1 target)")); @@ -2369,12 +2264,12 @@ INFO: Elapsed time: 1.234s"; #[test] fn test_filter_multi_root_respects_width() { - let stdout = "\ + let output = "\ //src/s1:a //src/s2:b //tools/t1:c //tools/t2:d"; - let result = filter_bazel_query(stdout, "", 1, 1); + let result = filter_bazel_query(output, 1, 1).unwrap(); assert!( result.contains("//src (2 targets"), @@ -2394,12 +2289,12 @@ INFO: Elapsed time: 1.234s"; #[test] fn test_filter_groups_external_repos() { - let stdout = "\ + let output = "\ //src/app:bin @abseil-cpp//absl/base:core_headers @abseil-cpp//absl/strings:str_format @zlib//:zlib"; - let result = filter_bazel_query(stdout, "", 1, 10); + let result = filter_bazel_query(output, 1, 10).unwrap(); assert!(result.contains("//src/app (1 target)")); assert!(result.contains("@abseil-cpp//absl (2 targets")); @@ -2411,11 +2306,11 @@ INFO: Elapsed time: 1.234s"; #[test] fn test_filter_consolidates_deep_common_prefix() { - let stdout = "\ + let output = "\ //src/java_tools/buildjar:a //src/java_tools/import_deps_checker:b //src/java_tools/junitrunner:c"; - let result = filter_bazel_query(stdout, "", 1, 10); + let result = filter_bazel_query(output, 1, 10).unwrap(); assert!(result.starts_with("//src/java_tools (3 targets")); assert!(result.contains("📦 buildjar (1 target)")); @@ -2425,12 +2320,12 @@ INFO: Elapsed time: 1.234s"; #[test] fn test_filter_splits_external_repos_by_repo_root() { - let stdout = "\ + let output = "\ @abseil-cpp//absl/base:core @abseil-cpp//absl/strings:format @bazel_skylib//lib:paths @bazel_skylib//rules:copy"; - let result = filter_bazel_query(stdout, "", 1, 10); + let result = filter_bazel_query(output, 1, 10).unwrap(); assert!(result.contains("@abseil-cpp//absl (2 targets")); assert!(result.contains("@bazel_skylib// (2 targets, 2 packages)")); @@ -2442,10 +2337,10 @@ INFO: Elapsed time: 1.234s"; #[test] fn test_filter_external_root_targets_keep_repo_root_header() { - let stdout = "\ + let output = "\ @abseil-cpp//:root_target @abseil-cpp//absl/base:core"; - let result = filter_bazel_query(stdout, "", 1, 10); + let result = filter_bazel_query(output, 1, 10).unwrap(); assert!(result.starts_with("@abseil-cpp// (2 targets, 2 packages)")); assert!(result.contains("📦 absl (1 target, 1 package)")); @@ -2454,11 +2349,11 @@ INFO: Elapsed time: 1.234s"; #[test] fn test_filter_depth_1_runtime_mode_stays_single_section() { - let stdout = "\ + let output = "\ //src:root //src/conditions:a //src/java_tools:b"; - let result = filter_bazel_query(stdout, "", 1, 10); + let result = filter_bazel_query(output, 1, 10).unwrap(); assert!(result.starts_with("//src (3 targets, 2 packages)")); assert!(result.contains("📦 conditions (1 target)")); @@ -2469,13 +2364,13 @@ INFO: Elapsed time: 1.234s"; #[test] fn test_filter_depth_2_runtime_mode_expands_to_sections() { - let stdout = "\ + let output = "\ //src:root_a //src:root_b //src/conditions:c1 //src/java_tools:j1 //src/java_tools/sub:s1"; - let result = filter_bazel_query(stdout, "", 2, 10); + let result = filter_bazel_query(output, 2, 10).unwrap(); assert!(result.contains("//src (2 targets)")); assert!(result.contains("🎯 :root_a")); @@ -2488,12 +2383,12 @@ INFO: Elapsed time: 1.234s"; #[test] fn test_filter_depth_2_skips_empty_intermediate_section() { - let stdout = "\ + let output = "\ @xds+//xds/data/orca:alpha @xds+//xds/data/orca:beta @xds+//xds/service/orca:gamma @xds+//xds/service/orca:delta"; - let result = filter_bazel_query(stdout, "", 2, 10); + let result = filter_bazel_query(output, 2, 10).unwrap(); assert!(!result.contains("@xds+//xds (0 targets)")); assert!(result.contains("@xds+//xds/data")); @@ -2502,9 +2397,9 @@ INFO: Elapsed time: 1.234s"; #[test] fn test_filter_error_only_no_empty_header() { - let stderr = + let output = "ERROR: Evaluation of query \"deps(//...)\" failed: preloading transitive closure failed"; - let result = filter_bazel_query("", stderr, usize::MAX, 10); + let result = filter_bazel_query(output, usize::MAX, 10).unwrap(); assert_eq!( result, @@ -2515,10 +2410,10 @@ INFO: Elapsed time: 1.234s"; #[test] fn test_filter_single_root_uses_subsections() { - let stdout = "\ + let output = "\ //src/lib:a //src/app:b"; - let result = filter_bazel_query(stdout, "", 2, usize::MAX); + let result = filter_bazel_query(output, 2, usize::MAX).unwrap(); assert!(result.contains("//src/app (1 target)")); assert!(result.contains("//src/lib (1 target)")); @@ -2535,7 +2430,7 @@ INFO: Elapsed time: 1.234s"; #[test] fn test_filter_all_pass() { - let original = "\ + let output = "\ INFO: Analyzed 3 targets (0 packages loaded, 0 targets configured). INFO: Found 1 target and 2 test targets... INFO: Elapsed time: 0.508s, Critical Path: 0.00s @@ -2543,13 +2438,13 @@ INFO: 1 process: 2 action cache hit, 1 internal. INFO: Build completed successfully, 1 total action //src/test/java/com/google/devtools/build/lib/runtime/commands/info:RemoteRequestedInfoItemHandlerTest PASSED in 2.4s //src/test/java/com/google/devtools/build/lib/runtime/commands/info:StdoutInfoItemHandlerTest PASSED in 1.5s."; - let result = filter_bazel_test(original); + let result = filter_bazel_test(output).unwrap(); assert_eq!(result, "\u{2713} bazel test: 2 passed, 0 failed (0.508s)"); } #[test] fn test_filter_with_cached() { - let stderr = "\ + let output = "\ Loading: INFO: Analyzed 2 targets (0 packages loaded, 0 targets configured). INFO: Found 2 test targets... @@ -2559,13 +2454,13 @@ INFO: Elapsed time: 0.412s, Critical Path: 0.10s INFO: 2 processes: 1 internal, 1 worker. INFO: Build completed successfully, 2 total actions Executed 1 out of 2 tests: 2 tests pass."; - let result = filter_bazel_test(stderr); + let result = filter_bazel_test(output).unwrap(); assert_eq!(result, "\u{2713} bazel test: 2 passed, 0 failed (0.412s)"); } #[test] fn test_filter_failure() { - let stderr = "\ + let output = "\ Loading: INFO: Analyzed 1 target (0 packages loaded, 0 targets configured). INFO: Found 1 test target... @@ -2576,7 +2471,7 @@ INFO: Elapsed time: 0.340s, Critical Path: 0.30s INFO: 2 processes: 1 internal, 1 worker. INFO: Build completed, 1 test FAILED, 2 total actions Executed 1 out of 1 test: 1 fails locally."; - let result = filter_bazel_test(stderr); + let result = filter_bazel_test(output).unwrap(); assert!(result.contains("bazel test: 1 failed, 0 passed (0.340s)")); assert!(result.contains("═══════════════════════════════════════")); @@ -2590,7 +2485,7 @@ Executed 1 out of 1 test: 1 fails locally."; #[test] fn test_filter_failure_with_test_output() { - let stderr = "\ + let output = "\ Loading: INFO: Analyzed 1 target (0 packages loaded, 0 targets configured). INFO: Found 1 test target... @@ -2609,7 +2504,7 @@ java.lang.Exception: No tests found matching RegEx[NONEXISTENT_TEST] INFO: Elapsed time: 0.340s, Critical Path: 0.30s INFO: Build completed, 1 test FAILED, 2 total actions Executed 1 out of 1 test: 1 fails locally."; - let result = filter_bazel_test(stderr); + let result = filter_bazel_test(output).unwrap(); assert!(result.contains("bazel test: 1 failed, 0 passed")); // Inline test output preserved @@ -2627,7 +2522,7 @@ Executed 1 out of 1 test: 1 fails locally."; #[test] fn test_filter_strips_build_noise() { - let stderr = "\ + let output = "\ Computing main repo mapping: Loading: Loading: 0 packages loaded @@ -2645,7 +2540,7 @@ bazel-bin/src/target INFO: Elapsed time: 1.00s, Critical Path: 0.50s INFO: Build completed successfully, 4 total actions Executed 1 out of 1 test: 1 tests pass."; - let result = filter_bazel_test(stderr); + let result = filter_bazel_test(output).unwrap(); assert!(!result.contains("Computing main repo")); assert!(!result.contains("Loading:")); @@ -2664,7 +2559,7 @@ Executed 1 out of 1 test: 1 tests pass."; #[test] fn test_filter_build_error() { - let stderr = "\ + let output = "\ Loading: WARNING: Target pattern parsing failed. ERROR: Skipping '//src:nonexistent': no such target '//src:nonexistent' @@ -2672,7 +2567,7 @@ ERROR: no such target '//src:nonexistent': target 'nonexistent' not declared INFO: Elapsed time: 0.142s INFO: 0 processes. ERROR: Build did NOT complete successfully"; - let result = filter_bazel_test(stderr); + let result = filter_bazel_test(output).unwrap(); assert!(result.contains("bazel test: build failed")); assert!(result.contains("═══════════════════════════════════════")); @@ -2684,13 +2579,13 @@ ERROR: Build did NOT complete successfully"; #[test] fn test_filter_empty() { - let result = filter_bazel_test(""); + let result = filter_bazel_test("").unwrap(); assert_eq!(result, "\u{2713} bazel test: 0 passed, 0 failed (0s)"); } #[test] fn test_filter_token_savings() { - let stderr = "\ + let output = "\ Computing main repo mapping: Loading: Loading: 0 packages loaded @@ -2713,8 +2608,8 @@ INFO: 6 processes: 3 internal, 3 worker. INFO: Build completed successfully, 6 total actions Executed 3 out of 3 tests: 3 tests pass."; - let input_tokens = count_tokens(stderr); - let result = filter_bazel_test(stderr); + let input_tokens = count_tokens(output); + let result = filter_bazel_test(output).unwrap(); let output_tokens = count_tokens(&result); let savings = 100.0 - (output_tokens as f64 / input_tokens as f64 * 100.0); @@ -2729,7 +2624,7 @@ Executed 3 out of 3 tests: 3 tests pass."; #[test] fn test_filter_strips_timeout_warnings() { - let stderr = "\ + let output = "\ INFO: Analyzed 1 target (0 packages loaded). INFO: Found 1 test target... //pkg:test PASSED in 0.5s @@ -2737,7 +2632,7 @@ There were tests whose specified size is too big. Use the --test_verbose_timeout INFO: Elapsed time: 1.00s, Critical Path: 0.50s INFO: Build completed successfully, 2 total actions Executed 1 out of 1 test: 1 tests pass."; - let result = filter_bazel_test(stderr); + let result = filter_bazel_test(output).unwrap(); assert!(!result.contains("There were tests whose specified size")); assert!(!result.contains("--test_verbose_timeout_warnings")); @@ -2766,7 +2661,7 @@ INFO: Running command line: bazel-bin/src/my_binary Hello from binary! Result: 42 "; - let result = filter_bazel_run(output); + let result = filter_bazel_run(output).unwrap(); // Clean output — no build summary, just binary output assert_eq!(result, "Hello from binary!\nResult: 42\n"); @@ -2785,7 +2680,7 @@ bazel-bin/src/app INFO: Build completed successfully, 100 total actions INFO: Running command line: bazel-bin/src/app app output here"; - let result = filter_bazel_run(output); + let result = filter_bazel_run(output).unwrap(); // Clean output - no warnings shown assert_eq!(result, "app output here"); @@ -2799,7 +2694,7 @@ ERROR: Skipping '//src:nonexistent': no such target '//src:nonexistent' INFO: Elapsed time: 0.142s INFO: 0 processes. ERROR: Build did NOT complete successfully"; - let result = filter_bazel_run(output); + let result = filter_bazel_run(output).unwrap(); assert!(result.contains("bazel run: 1 error")); assert!(result.contains("ERROR: Skipping")); @@ -2816,7 +2711,7 @@ ERROR: no such target '//src:nonexistent': target 'nonexistent' not declared INFO: Elapsed time: 0.142s INFO: 0 processes. ERROR: Build did NOT complete successfully"; - let result = filter_bazel_run(output); + let result = filter_bazel_run(output).unwrap(); assert!(result.contains("bazel run: 2 errors")); } @@ -2832,7 +2727,7 @@ WARNING: File not found: /home/user/foo/bar.txt INFO: Elapsed time: 0.142s INFO: 0 processes. ERROR: Build did NOT complete successfully"; - let result = filter_bazel_run(output); + let result = filter_bazel_run(output).unwrap(); assert!( result.contains("bazel run:") @@ -2858,13 +2753,13 @@ INFO: Found 1 target... Target //src:app up-to-date: bazel-bin/src/app INFO: Build completed successfully, 100 total actions"; - let result = filter_bazel_run(output); + let result = filter_bazel_run(output).unwrap(); assert!(result.is_empty()); } #[test] fn test_filter_empty() { - let result = filter_bazel_run(""); + let result = filter_bazel_run("").unwrap(); assert!(result.is_empty()); } @@ -2911,7 +2806,7 @@ INFO: Running command line: bazel-bin/src/my_binary Hello World"; let input_tokens = count_tokens(original); - let result = filter_bazel_run(prefiltered); + let result = filter_bazel_run(prefiltered).unwrap(); let output_tokens = count_tokens(&result); let savings = 100.0 - (output_tokens as f64 / input_tokens as f64 * 100.0); @@ -2937,7 +2832,7 @@ WARNING: App warning DEBUG: App debug ERROR: App error "; - let result = filter_bazel_run(output); + let result = filter_bazel_run(output).unwrap(); assert!(result.contains("INFO: Some trailing info line")); assert!(result.contains("WARNING: App warning")); @@ -2956,7 +2851,7 @@ line1 line2 "; - let result = filter_bazel_run(output); + let result = filter_bazel_run(output).unwrap(); assert_eq!(result, "line1\n\nline2\n\n"); } @@ -2969,7 +2864,7 @@ line2 "\tTabbed\n", ); let expected = concat!(" indented\n", "\tTabbed\n",); - let actual = filter_bazel_run(output); + let actual = filter_bazel_run(output).unwrap(); assert_eq!(expected, actual); } @@ -2982,7 +2877,7 @@ line2 " hello! oops I left some trailing whitespace \n", ); let expected = " hello! oops I left some trailing whitespace \n"; - let actual = filter_bazel_run(output); + let actual = filter_bazel_run(output).unwrap(); assert_eq!(expected, actual); } diff --git a/src/main.rs b/src/main.rs index d10990e9..8981f1bf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1574,7 +1574,7 @@ fn main() -> Result<()> { bazel_cmd::run_test(&args, cli.verbose)?; } BazelCommands::Other(args) => { - bazel_cmd::run_other(&args, cli.verbose)?; + bazel_cmd::run_passthrough(&args, cli.verbose)?; } },