2808 lines
95 KiB
Rust
2808 lines
95 KiB
Rust
//! Filters cargo output — build errors, test results, clippy warnings.
|
|
|
|
use crate::core::args_utils;
|
|
use crate::core::runner;
|
|
use crate::core::stream::{BlockHandler, BlockStreamFilter, StreamFilter};
|
|
use crate::core::truncate::{CAP_ERRORS, CAP_LIST, CAP_WARNINGS};
|
|
use crate::core::utils::{join_with_overflow, resolved_command, truncate};
|
|
use anyhow::Result;
|
|
use serde::Deserialize;
|
|
use std::cmp::Ordering;
|
|
use std::collections::HashMap;
|
|
use std::ffi::OsString;
|
|
use std::sync::OnceLock;
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum CargoCommand {
|
|
Build,
|
|
Test,
|
|
Clippy,
|
|
Check,
|
|
Install,
|
|
Nextest,
|
|
}
|
|
|
|
pub fn run(cmd: CargoCommand, args: &[String], verbose: u8) -> Result<i32> {
|
|
match cmd {
|
|
CargoCommand::Build => run_build(args, verbose),
|
|
CargoCommand::Test => run_test(args, verbose),
|
|
CargoCommand::Clippy => run_clippy(args, verbose),
|
|
CargoCommand::Check => run_check(args, verbose),
|
|
CargoCommand::Install => run_install(args, verbose),
|
|
CargoCommand::Nextest => run_nextest(args, verbose),
|
|
}
|
|
}
|
|
|
|
// --- Stream handlers ---
|
|
|
|
struct CargoBuildHandler {
|
|
compiled: usize,
|
|
warnings: usize,
|
|
error_count: usize,
|
|
finished_line: Option<String>,
|
|
label: &'static str,
|
|
}
|
|
|
|
impl CargoBuildHandler {
|
|
fn with_label(label: &'static str) -> Self {
|
|
Self {
|
|
compiled: 0,
|
|
warnings: 0,
|
|
error_count: 0,
|
|
finished_line: None,
|
|
label,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl BlockHandler for CargoBuildHandler {
|
|
fn should_skip(&mut self, line: &str) -> bool {
|
|
let trimmed = line.trim_start();
|
|
if trimmed.starts_with("Compiling") || trimmed.starts_with("Checking") {
|
|
self.compiled += 1;
|
|
return true;
|
|
}
|
|
if trimmed.starts_with("Downloading") || trimmed.starts_with("Downloaded") {
|
|
return true;
|
|
}
|
|
if trimmed.starts_with("Finished") {
|
|
self.finished_line = Some(trimmed.to_string());
|
|
return true;
|
|
}
|
|
if line.starts_with("warning:") && line.contains("generated") && line.contains("warning") {
|
|
return true;
|
|
}
|
|
if (line.starts_with("error:") || line.starts_with("error["))
|
|
&& (line.contains("aborting due to") || line.contains("could not compile"))
|
|
{
|
|
return true;
|
|
}
|
|
false
|
|
}
|
|
|
|
fn is_block_start(&mut self, line: &str) -> bool {
|
|
if line.starts_with("error[") || line.starts_with("error:") {
|
|
self.error_count += 1;
|
|
return true;
|
|
}
|
|
if line.starts_with("warning:") || line.starts_with("warning[") {
|
|
self.warnings += 1;
|
|
return true;
|
|
}
|
|
false
|
|
}
|
|
|
|
fn is_block_continuation(&mut self, line: &str, block: &[String]) -> bool {
|
|
!(line.trim().is_empty() && block.len() > 3)
|
|
}
|
|
|
|
fn format_summary(&self, exit_code: i32, _raw: &str) -> Option<String> {
|
|
if self.error_count == 0 && self.warnings == 0 && exit_code == 0 {
|
|
return Some(cargo_build_success_line(
|
|
self.compiled,
|
|
self.finished_line.as_deref(),
|
|
self.label,
|
|
));
|
|
}
|
|
// The streamed path only runs for non-json build/check; error blocks are
|
|
// emitted live, so the summary carries no rendered diagnostics.
|
|
let empty = JsonDiagnostics {
|
|
errors: Vec::new(),
|
|
warnings: Vec::new(),
|
|
};
|
|
Some(cargo_build_failure_summary(
|
|
self.compiled,
|
|
self.error_count,
|
|
self.warnings,
|
|
&empty,
|
|
self.label,
|
|
exit_code,
|
|
))
|
|
}
|
|
}
|
|
|
|
struct CargoTestHandler {
|
|
in_failure_section: bool,
|
|
in_failure_names: bool,
|
|
summary_lines: Vec<String>,
|
|
has_compile_errors: bool,
|
|
}
|
|
|
|
impl CargoTestHandler {
|
|
fn new() -> Self {
|
|
Self {
|
|
in_failure_section: false,
|
|
in_failure_names: false,
|
|
summary_lines: Vec::new(),
|
|
has_compile_errors: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl BlockHandler for CargoTestHandler {
|
|
fn should_skip(&mut self, line: &str) -> bool {
|
|
let trimmed = line.trim_start();
|
|
if trimmed.starts_with("Compiling")
|
|
|| trimmed.starts_with("Downloading")
|
|
|| trimmed.starts_with("Downloaded")
|
|
|| trimmed.starts_with("Finished")
|
|
{
|
|
return true;
|
|
}
|
|
if line.starts_with("running ") {
|
|
return true;
|
|
}
|
|
if line.starts_with("test ") && line.ends_with("... ok") {
|
|
return true;
|
|
}
|
|
// Track compile errors for fallback
|
|
if trimmed.starts_with("error[") || trimmed.starts_with("error:") {
|
|
self.has_compile_errors = true;
|
|
}
|
|
// "failures:" toggles section state
|
|
if line == "failures:" {
|
|
if self.in_failure_section {
|
|
// Second "failures:" = list of failure names — skip them
|
|
self.in_failure_names = true;
|
|
}
|
|
self.in_failure_section = true;
|
|
return true;
|
|
}
|
|
// Skip the failure name listing section
|
|
if self.in_failure_names {
|
|
if line.starts_with("test result:") {
|
|
self.in_failure_names = false;
|
|
self.in_failure_section = false;
|
|
self.summary_lines.push(line.to_string());
|
|
return true;
|
|
}
|
|
return true;
|
|
}
|
|
if line.starts_with("test result:") {
|
|
self.summary_lines.push(line.to_string());
|
|
self.in_failure_section = false;
|
|
return true;
|
|
}
|
|
false
|
|
}
|
|
|
|
fn is_block_start(&mut self, line: &str) -> bool {
|
|
self.in_failure_section && line.starts_with("---- ")
|
|
}
|
|
|
|
fn is_block_continuation(&mut self, line: &str, _block: &[String]) -> bool {
|
|
self.in_failure_section && !line.starts_with("---- ")
|
|
}
|
|
|
|
fn format_summary(&self, _exit_code: i32, raw: &str) -> Option<String> {
|
|
if self.summary_lines.is_empty() {
|
|
let json = extract_json_diagnostics(raw);
|
|
if self.has_compile_errors || !json.errors.is_empty() {
|
|
// Content-based (exit 0): a real compile error yields "cargo test: N
|
|
// errors"; a bare "could not compile" leaves the raw tail fallback.
|
|
let build_filtered = filter_cargo_build_labeled(raw, "test", 0);
|
|
if build_filtered.contains("cargo test:") {
|
|
return Some(format!("{}\n", build_filtered));
|
|
}
|
|
// Fallback: last 5 meaningful lines
|
|
let meaningful: Vec<&str> = raw
|
|
.lines()
|
|
.filter(|l| !l.trim().is_empty() && !l.trim_start().starts_with("Compiling"))
|
|
.collect();
|
|
let last5: Vec<&str> = meaningful.iter().rev().take(5).rev().copied().collect();
|
|
return Some(format!("{}\n", last5.join("\n")));
|
|
}
|
|
}
|
|
|
|
// No failures emitted — aggregate pass results
|
|
let mut aggregated: Option<AggregatedTestResult> = None;
|
|
let mut all_parsed = true;
|
|
|
|
for line in &self.summary_lines {
|
|
if let Some(parsed) = AggregatedTestResult::parse_line(line) {
|
|
if let Some(ref mut agg) = aggregated {
|
|
agg.merge(&parsed);
|
|
} else {
|
|
aggregated = Some(parsed);
|
|
}
|
|
} else {
|
|
all_parsed = false;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if all_parsed {
|
|
if let Some(agg) = aggregated {
|
|
if agg.suites > 0 {
|
|
return Some(format!("{}\n", agg.format_compact()));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fallback: show raw summary lines
|
|
if !self.summary_lines.is_empty() {
|
|
let mut s = String::new();
|
|
for line in &self.summary_lines {
|
|
s.push_str(line);
|
|
s.push('\n');
|
|
}
|
|
return Some(s);
|
|
}
|
|
|
|
None
|
|
}
|
|
}
|
|
|
|
/// Generic cargo command runner with filtering.
|
|
/// Builds the Command with restored `--` separator, then delegates to shared runner.
|
|
fn run_cargo_filtered<F>(
|
|
subcommand: &str,
|
|
args: &[String],
|
|
verbose: u8,
|
|
filter_fn: F,
|
|
) -> Result<i32>
|
|
where
|
|
F: Fn(&str) -> String,
|
|
{
|
|
let mut cmd = resolved_command("cargo");
|
|
cmd.arg(subcommand);
|
|
|
|
let restored_args = args_utils::restore_double_dash(args);
|
|
for arg in &restored_args {
|
|
cmd.arg(arg);
|
|
}
|
|
|
|
if verbose > 0 {
|
|
eprintln!("Running: cargo {} {}", subcommand, restored_args.join(" "));
|
|
}
|
|
|
|
runner::run_filtered(
|
|
cmd,
|
|
&format!("cargo {}", subcommand),
|
|
&restored_args.join(" "),
|
|
filter_fn,
|
|
runner::RunOptions::with_tee(&format!("cargo_{}", subcommand)),
|
|
)
|
|
}
|
|
|
|
/// Same as `run_cargo_filtered` but the filter also receives the child exit code,
|
|
/// so it can tell a genuine failure from a clean run when no diagnostics parse.
|
|
fn run_cargo_filtered_with_exit<F>(
|
|
subcommand: &str,
|
|
args: &[String],
|
|
verbose: u8,
|
|
filter_fn: F,
|
|
) -> Result<i32>
|
|
where
|
|
F: Fn(&str, i32) -> String,
|
|
{
|
|
let mut cmd = resolved_command("cargo");
|
|
cmd.arg(subcommand);
|
|
|
|
let restored_args = args_utils::restore_double_dash(args);
|
|
for arg in &restored_args {
|
|
cmd.arg(arg);
|
|
}
|
|
|
|
if verbose > 0 {
|
|
eprintln!("Running: cargo {} {}", subcommand, restored_args.join(" "));
|
|
}
|
|
|
|
runner::run_filtered_with_exit(
|
|
cmd,
|
|
&format!("cargo {}", subcommand),
|
|
&restored_args.join(" "),
|
|
filter_fn,
|
|
runner::RunOptions::with_tee(&format!("cargo_{}", subcommand)),
|
|
)
|
|
}
|
|
|
|
fn run_cargo_streamed(
|
|
subcommand: &str,
|
|
args: &[String],
|
|
verbose: u8,
|
|
filter: Box<dyn StreamFilter>,
|
|
) -> Result<i32> {
|
|
let mut cmd = resolved_command("cargo");
|
|
cmd.arg(subcommand);
|
|
|
|
let restored_args = args_utils::restore_double_dash(args);
|
|
for arg in &restored_args {
|
|
cmd.arg(arg);
|
|
}
|
|
|
|
if verbose > 0 {
|
|
eprintln!("Running: cargo {} {}", subcommand, restored_args.join(" "));
|
|
}
|
|
|
|
runner::run_streamed(
|
|
cmd,
|
|
&format!("cargo {}", subcommand),
|
|
&restored_args.join(" "),
|
|
filter,
|
|
runner::RunOptions::with_tee(&format!("cargo_{}", subcommand)),
|
|
)
|
|
}
|
|
|
|
fn has_json_message_format(args: &[String]) -> bool {
|
|
let mut json = false;
|
|
let mut iter = args.iter();
|
|
while let Some(arg) = iter.next() {
|
|
if let Some(val) = arg.strip_prefix("--message-format=") {
|
|
json = val.contains("json");
|
|
} else if arg == "--message-format" {
|
|
if let Some(val) = iter.next() {
|
|
json = val.contains("json");
|
|
}
|
|
}
|
|
}
|
|
json
|
|
}
|
|
|
|
fn run_build(args: &[String], verbose: u8) -> Result<i32> {
|
|
if has_json_message_format(args) {
|
|
return run_cargo_filtered_with_exit("build", args, verbose, |o, exit| {
|
|
filter_cargo_build_labeled(o, "build", exit)
|
|
});
|
|
}
|
|
run_cargo_streamed(
|
|
"build",
|
|
args,
|
|
verbose,
|
|
Box::new(BlockStreamFilter::new(CargoBuildHandler::with_label("build"))),
|
|
)
|
|
}
|
|
|
|
fn run_test(args: &[String], verbose: u8) -> Result<i32> {
|
|
// No json branch here on purpose: --message-format=json only reformats the
|
|
// build phase, the test harness output stays human-readable. CargoTestHandler
|
|
// reads both — it aggregates the `test result:` lines and, on a compile error,
|
|
// parses the json diagnostics in format_summary.
|
|
run_cargo_streamed(
|
|
"test",
|
|
args,
|
|
verbose,
|
|
Box::new(BlockStreamFilter::new(CargoTestHandler::new())),
|
|
)
|
|
}
|
|
|
|
fn run_clippy(args: &[String], verbose: u8) -> Result<i32> {
|
|
if has_json_message_format(args) {
|
|
return run_cargo_filtered_with_exit("clippy", args, verbose, filter_cargo_clippy_json);
|
|
}
|
|
run_cargo_filtered("clippy", args, verbose, filter_cargo_clippy)
|
|
}
|
|
|
|
fn run_check(args: &[String], verbose: u8) -> Result<i32> {
|
|
if has_json_message_format(args) {
|
|
return run_cargo_filtered_with_exit("check", args, verbose, |o, exit| {
|
|
filter_cargo_build_labeled(o, "check", exit)
|
|
});
|
|
}
|
|
run_cargo_streamed(
|
|
"check",
|
|
args,
|
|
verbose,
|
|
Box::new(BlockStreamFilter::new(CargoBuildHandler::with_label("check"))),
|
|
)
|
|
}
|
|
|
|
fn run_install(args: &[String], verbose: u8) -> Result<i32> {
|
|
run_cargo_filtered("install", args, verbose, filter_cargo_install)
|
|
}
|
|
|
|
fn run_nextest(args: &[String], verbose: u8) -> Result<i32> {
|
|
run_cargo_filtered("nextest", args, verbose, filter_cargo_nextest)
|
|
}
|
|
|
|
/// Format crate name + version into a display string
|
|
fn format_crate_info(name: &str, version: &str, fallback: &str) -> String {
|
|
if name.is_empty() {
|
|
fallback.to_string()
|
|
} else if version.is_empty() {
|
|
name.to_string()
|
|
} else {
|
|
format!("{} {}", name, version)
|
|
}
|
|
}
|
|
|
|
/// Filter cargo install output - strip dep compilation, keep installed/replaced/errors
|
|
fn filter_cargo_install(output: &str) -> String {
|
|
let mut errors: Vec<String> = Vec::new();
|
|
let mut error_count = 0;
|
|
let mut compiled = 0;
|
|
let mut in_error = false;
|
|
let mut current_error = Vec::new();
|
|
let mut installed_crate = String::new();
|
|
let mut installed_version = String::new();
|
|
let mut replaced_lines: Vec<String> = Vec::new();
|
|
let mut already_installed = false;
|
|
let mut ignored_line = String::new();
|
|
|
|
for line in output.lines() {
|
|
let trimmed = line.trim_start();
|
|
|
|
// Strip noise: dep compilation, downloading, locking, etc.
|
|
if trimmed.starts_with("Compiling") {
|
|
compiled += 1;
|
|
continue;
|
|
}
|
|
if trimmed.starts_with("Downloading")
|
|
|| trimmed.starts_with("Downloaded")
|
|
|| trimmed.starts_with("Locking")
|
|
|| trimmed.starts_with("Updating")
|
|
|| trimmed.starts_with("Adding")
|
|
|| trimmed.starts_with("Finished")
|
|
|| trimmed.starts_with("Blocking waiting for file lock")
|
|
{
|
|
continue;
|
|
}
|
|
|
|
// Keep: Installing line (extract crate name + version)
|
|
if trimmed.starts_with("Installing") {
|
|
let rest = trimmed.strip_prefix("Installing").unwrap_or("").trim();
|
|
if !rest.is_empty() && !rest.starts_with('/') {
|
|
if let Some((name, version)) = rest.split_once(' ') {
|
|
installed_crate = name.to_string();
|
|
installed_version = version.to_string();
|
|
} else {
|
|
installed_crate = rest.to_string();
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Keep: Installed line (extract crate + version if not already set)
|
|
if trimmed.starts_with("Installed") {
|
|
let rest = trimmed.strip_prefix("Installed").unwrap_or("").trim();
|
|
if !rest.is_empty() && installed_crate.is_empty() {
|
|
let mut parts = rest.split_whitespace();
|
|
if let (Some(name), Some(version)) = (parts.next(), parts.next()) {
|
|
installed_crate = name.to_string();
|
|
installed_version = version.to_string();
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Keep: Replacing/Replaced lines
|
|
if trimmed.starts_with("Replacing") || trimmed.starts_with("Replaced") {
|
|
replaced_lines.push(trimmed.to_string());
|
|
continue;
|
|
}
|
|
|
|
// Keep: "Ignored package" (already up to date)
|
|
if trimmed.starts_with("Ignored package") {
|
|
already_installed = true;
|
|
ignored_line = trimmed.to_string();
|
|
continue;
|
|
}
|
|
|
|
// Keep: actionable warnings (e.g., "be sure to add `/path` to your PATH")
|
|
// Skip summary lines like "warning: `crate` generated N warnings"
|
|
if line.starts_with("warning:") {
|
|
if !(line.contains("generated") && line.contains("warning")) {
|
|
replaced_lines.push(line.to_string());
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Detect error blocks
|
|
if line.starts_with("error[") || line.starts_with("error:") {
|
|
if line.contains("aborting due to") || line.contains("could not compile") {
|
|
continue;
|
|
}
|
|
if in_error && !current_error.is_empty() {
|
|
errors.push(current_error.join("\n"));
|
|
current_error.clear();
|
|
}
|
|
error_count += 1;
|
|
in_error = true;
|
|
current_error.push(line.to_string());
|
|
} else if in_error {
|
|
if line.trim().is_empty() && current_error.len() > 3 {
|
|
errors.push(current_error.join("\n"));
|
|
current_error.clear();
|
|
in_error = false;
|
|
} else {
|
|
current_error.push(line.to_string());
|
|
}
|
|
}
|
|
}
|
|
|
|
if !current_error.is_empty() {
|
|
errors.push(current_error.join("\n"));
|
|
}
|
|
|
|
// Already installed / up to date
|
|
if already_installed {
|
|
let info = ignored_line.split('`').nth(1).unwrap_or(&ignored_line);
|
|
return format!("cargo install: {} already installed", info);
|
|
}
|
|
|
|
// Errors
|
|
if error_count > 0 {
|
|
let crate_info = format_crate_info(&installed_crate, &installed_version, "");
|
|
let deps_info = if compiled > 0 {
|
|
format!(", {} deps compiled", compiled)
|
|
} else {
|
|
String::new()
|
|
};
|
|
|
|
let mut result = String::new();
|
|
if crate_info.is_empty() {
|
|
result.push_str(&format!(
|
|
"cargo install: {} error{}{}\n",
|
|
error_count,
|
|
if error_count > 1 { "s" } else { "" },
|
|
deps_info
|
|
));
|
|
} else {
|
|
result.push_str(&format!(
|
|
"cargo install: {} error{} ({}{})\n",
|
|
error_count,
|
|
if error_count > 1 { "s" } else { "" },
|
|
crate_info,
|
|
deps_info
|
|
));
|
|
}
|
|
|
|
const MAX_INSTALL_ERRORS: usize = CAP_ERRORS;
|
|
for (i, err) in errors.iter().enumerate().take(MAX_INSTALL_ERRORS) {
|
|
result.push_str(err);
|
|
result.push('\n');
|
|
if i < errors.len() - 1 {
|
|
result.push('\n');
|
|
}
|
|
}
|
|
|
|
if errors.len() > MAX_INSTALL_ERRORS {
|
|
result.push_str(&format!(
|
|
"\n… +{} more issues\n",
|
|
errors.len() - MAX_INSTALL_ERRORS
|
|
));
|
|
let all_errors = errors.join("\n\n");
|
|
if let Some(hint) = crate::core::tee::force_tee_hint(&all_errors, "cargo-build-errors")
|
|
{
|
|
result.push_str(&format!(" {}\n", hint));
|
|
}
|
|
}
|
|
|
|
return result.trim().to_string();
|
|
}
|
|
|
|
// Success
|
|
let crate_info = format_crate_info(&installed_crate, &installed_version, "package");
|
|
|
|
let mut result = format!("cargo install ({}, {} deps compiled)", crate_info, compiled);
|
|
|
|
for line in &replaced_lines {
|
|
result.push_str(&format!("\n {}", line));
|
|
}
|
|
|
|
result
|
|
}
|
|
|
|
/// Push a completed failure block (header + body) into the failures list, then clear the buffers.
|
|
fn flush_failure_block(header: &mut String, body: &mut Vec<String>, failures: &mut Vec<String>) {
|
|
if header.is_empty() {
|
|
return;
|
|
}
|
|
let mut block = header.clone();
|
|
if !body.is_empty() {
|
|
block.push('\n');
|
|
block.push_str(&body.join("\n"));
|
|
}
|
|
failures.push(block);
|
|
header.clear();
|
|
body.clear();
|
|
}
|
|
|
|
/// Filter cargo nextest output - show failures + compact summary
|
|
fn filter_cargo_nextest(output: &str) -> String {
|
|
static SUMMARY_RE: OnceLock<regex::Regex> = OnceLock::new();
|
|
let summary_re = SUMMARY_RE.get_or_init(|| {
|
|
regex::Regex::new(
|
|
r"Summary \[\s*([\d.]+)s\]\s+(\d+) tests? run:\s+(\d+) passed(?:,\s+(\d+) failed)?(?:,\s+(\d+) skipped)?"
|
|
).expect("invalid nextest summary regex")
|
|
});
|
|
|
|
static STARTING_RE: OnceLock<regex::Regex> = OnceLock::new();
|
|
let starting_re = STARTING_RE.get_or_init(|| {
|
|
regex::Regex::new(r"Starting \d+ tests? across (\d+) binar(?:y|ies)")
|
|
.expect("invalid nextest starting regex")
|
|
});
|
|
|
|
let mut failures: Vec<String> = Vec::new();
|
|
let mut in_failure_block = false;
|
|
let mut past_summary = false;
|
|
let mut current_failure_header = String::new();
|
|
let mut current_failure_body = Vec::new();
|
|
let mut summary_line = String::new();
|
|
let mut binaries: u32 = 0;
|
|
let mut has_cancel_line = false;
|
|
|
|
for line in output.lines() {
|
|
let trimmed = line.trim();
|
|
|
|
// Strip compilation noise
|
|
if trimmed.starts_with("Compiling")
|
|
|| trimmed.starts_with("Downloading")
|
|
|| trimmed.starts_with("Downloaded")
|
|
|| trimmed.starts_with("Finished")
|
|
|| trimmed.starts_with("Locking")
|
|
|| trimmed.starts_with("Updating")
|
|
{
|
|
continue;
|
|
}
|
|
|
|
// Strip separator lines (────)
|
|
if trimmed.starts_with("────") {
|
|
continue;
|
|
}
|
|
|
|
// Skip post-summary recap lines (FAIL duplicates + "error: test run failed")
|
|
if past_summary {
|
|
continue;
|
|
}
|
|
|
|
// Parse binary count from Starting line
|
|
if trimmed.starts_with("Starting") {
|
|
if let Some(caps) = starting_re.captures(trimmed) {
|
|
if let Some(m) = caps.get(1) {
|
|
binaries = m.as_str().parse().unwrap_or(0);
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Strip PASS lines
|
|
if trimmed.starts_with("PASS") {
|
|
if in_failure_block {
|
|
flush_failure_block(
|
|
&mut current_failure_header,
|
|
&mut current_failure_body,
|
|
&mut failures,
|
|
);
|
|
in_failure_block = false;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Detect FAIL lines
|
|
if trimmed.starts_with("FAIL") {
|
|
// Close previous failure block if any
|
|
if in_failure_block {
|
|
flush_failure_block(
|
|
&mut current_failure_header,
|
|
&mut current_failure_body,
|
|
&mut failures,
|
|
);
|
|
}
|
|
current_failure_header = trimmed.to_string();
|
|
in_failure_block = true;
|
|
continue;
|
|
}
|
|
|
|
// Cancellation notice
|
|
if trimmed.starts_with("Cancelling") || trimmed.starts_with("Canceling") {
|
|
has_cancel_line = true;
|
|
continue;
|
|
}
|
|
|
|
// Nextest run ID line
|
|
if trimmed.starts_with("Nextest run ID") {
|
|
continue;
|
|
}
|
|
|
|
// Parse summary
|
|
if trimmed.starts_with("Summary") {
|
|
summary_line = trimmed.to_string();
|
|
if in_failure_block {
|
|
flush_failure_block(
|
|
&mut current_failure_header,
|
|
&mut current_failure_body,
|
|
&mut failures,
|
|
);
|
|
in_failure_block = false;
|
|
}
|
|
past_summary = true;
|
|
continue;
|
|
}
|
|
|
|
// Collect failure body lines (stdout/stderr sections)
|
|
if in_failure_block {
|
|
current_failure_body.push(line.to_string());
|
|
}
|
|
}
|
|
|
|
// Close last failure block
|
|
if in_failure_block {
|
|
flush_failure_block(
|
|
&mut current_failure_header,
|
|
&mut current_failure_body,
|
|
&mut failures,
|
|
);
|
|
}
|
|
|
|
// Parse summary with regex
|
|
if let Some(caps) = summary_re.captures(&summary_line) {
|
|
let duration = caps.get(1).map_or("?", |m| m.as_str());
|
|
let passed: u32 = caps
|
|
.get(3)
|
|
.and_then(|m| m.as_str().parse().ok())
|
|
.unwrap_or(0);
|
|
let failed: u32 = caps
|
|
.get(4)
|
|
.and_then(|m| m.as_str().parse().ok())
|
|
.unwrap_or(0);
|
|
let skipped: u32 = caps
|
|
.get(5)
|
|
.and_then(|m| m.as_str().parse().ok())
|
|
.unwrap_or(0);
|
|
|
|
let binary_text = match binaries.cmp(&1) {
|
|
Ordering::Greater => format!("{} binaries", binaries),
|
|
Ordering::Equal => "1 binary".to_string(),
|
|
Ordering::Less => String::new(),
|
|
};
|
|
|
|
if failed == 0 {
|
|
// All pass - compact single line
|
|
let mut parts = vec![format!("{} passed", passed)];
|
|
if skipped > 0 {
|
|
parts.push(format!("{} skipped", skipped));
|
|
}
|
|
let meta = if binary_text.is_empty() {
|
|
format!("{}s", duration)
|
|
} else {
|
|
format!("{}, {}s", binary_text, duration)
|
|
};
|
|
return format!("cargo nextest: {} ({})", parts.join(", "), meta);
|
|
}
|
|
|
|
// With failures - show failure details then summary
|
|
let mut result = String::new();
|
|
|
|
for failure in &failures {
|
|
result.push_str(failure);
|
|
result.push('\n');
|
|
}
|
|
|
|
if has_cancel_line {
|
|
result.push_str("Cancelling due to test failure\n");
|
|
}
|
|
|
|
let mut summary_parts = vec![format!("{} passed", passed)];
|
|
if failed > 0 {
|
|
summary_parts.push(format!("{} failed", failed));
|
|
}
|
|
if skipped > 0 {
|
|
summary_parts.push(format!("{} skipped", skipped));
|
|
}
|
|
let meta = if binary_text.is_empty() {
|
|
format!("{}s", duration)
|
|
} else {
|
|
format!("{}, {}s", binary_text, duration)
|
|
};
|
|
result.push_str(&format!(
|
|
"cargo nextest: {} ({})",
|
|
summary_parts.join(", "),
|
|
meta
|
|
));
|
|
|
|
return result.trim().to_string();
|
|
}
|
|
|
|
// Fallback: if summary regex didn't match, show what we have
|
|
if !failures.is_empty() {
|
|
let mut result = String::new();
|
|
for failure in &failures {
|
|
result.push_str(failure);
|
|
result.push('\n');
|
|
}
|
|
if !summary_line.is_empty() {
|
|
result.push_str(&summary_line);
|
|
}
|
|
return result.trim().to_string();
|
|
}
|
|
|
|
if !summary_line.is_empty() {
|
|
return summary_line;
|
|
}
|
|
|
|
// Empty or unrecognized
|
|
String::new()
|
|
}
|
|
|
|
struct JsonDiagnostics {
|
|
errors: Vec<String>,
|
|
warnings: Vec<String>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct CargoJsonLine {
|
|
reason: String,
|
|
message: Option<CargoDiagnostic>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct CargoDiagnostic {
|
|
level: String,
|
|
#[serde(default)]
|
|
message: String,
|
|
rendered: Option<String>,
|
|
}
|
|
|
|
fn extract_json_diagnostics(raw: &str) -> JsonDiagnostics {
|
|
let mut errors = Vec::new();
|
|
let mut warnings = Vec::new();
|
|
for line in raw.lines() {
|
|
let line = line.trim_start();
|
|
if !line.starts_with('{') {
|
|
continue;
|
|
}
|
|
let Ok(entry) = serde_json::from_str::<CargoJsonLine>(line) else {
|
|
continue;
|
|
};
|
|
if entry.reason != "compiler-message" {
|
|
continue;
|
|
}
|
|
let Some(msg) = entry.message else {
|
|
continue;
|
|
};
|
|
let bucket = match msg.level.as_str() {
|
|
"error" | "error: internal compiler error" => &mut errors,
|
|
"warning" => &mut warnings,
|
|
_ => continue,
|
|
};
|
|
if msg.message.starts_with("aborting due to")
|
|
|| msg.message.starts_with("could not compile")
|
|
|| (msg.message.contains("warning") && msg.message.contains("generated"))
|
|
{
|
|
continue;
|
|
}
|
|
if let Some(rendered) = msg.rendered {
|
|
bucket.push(crate::core::utils::strip_ansi(&rendered).trim_end().to_string());
|
|
} else if !msg.message.is_empty() {
|
|
bucket.push(msg.message);
|
|
}
|
|
}
|
|
JsonDiagnostics { errors, warnings }
|
|
}
|
|
|
|
fn merge_diag_counts(error_count: usize, warnings: usize, json: &JsonDiagnostics) -> (usize, usize) {
|
|
(
|
|
error_count.max(json.errors.len()),
|
|
warnings.max(json.warnings.len()),
|
|
)
|
|
}
|
|
|
|
fn cargo_build_success_line(compiled: usize, finished: Option<&str>, label: &str) -> String {
|
|
match finished {
|
|
Some(f) => format!("cargo {} ({} crates compiled)\n{}\n", label, compiled, f),
|
|
None => format!("cargo {} ({} crates compiled)\n", label, compiled),
|
|
}
|
|
}
|
|
|
|
fn cargo_build_failure_summary(
|
|
compiled: usize,
|
|
errors: usize,
|
|
warnings: usize,
|
|
json: &JsonDiagnostics,
|
|
label: &str,
|
|
exit_code: i32,
|
|
) -> String {
|
|
let mut out = if errors == 0 && warnings == 0 {
|
|
format!("cargo {}: failed (exit {})\n", label, exit_code)
|
|
} else {
|
|
format!(
|
|
"cargo {}: {} errors, {} warnings ({} crates)\n",
|
|
label, errors, warnings, compiled
|
|
)
|
|
};
|
|
if !json.errors.is_empty() {
|
|
let shown: Vec<String> = json.errors.iter().take(CAP_ERRORS).cloned().collect();
|
|
out.push_str(&join_with_overflow(
|
|
&shown,
|
|
json.errors.len(),
|
|
CAP_ERRORS,
|
|
"errors",
|
|
));
|
|
out.push('\n');
|
|
}
|
|
if !json.warnings.is_empty() {
|
|
let shown: Vec<String> = json.warnings.iter().take(CAP_WARNINGS).cloned().collect();
|
|
out.push_str(&join_with_overflow(
|
|
&shown,
|
|
json.warnings.len(),
|
|
CAP_WARNINGS,
|
|
"warnings",
|
|
));
|
|
out.push('\n');
|
|
}
|
|
if json.errors.len() > CAP_ERRORS || json.warnings.len() > CAP_WARNINGS {
|
|
let full = json
|
|
.errors
|
|
.iter()
|
|
.chain(json.warnings.iter())
|
|
.map(String::as_str)
|
|
.collect::<Vec<_>>()
|
|
.join("\n\n");
|
|
if let Some(hint) = crate::core::tee::force_tee_hint(&full, "cargo-json-issues") {
|
|
out.push_str(&format!(" {}\n", hint));
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn filter_cargo_build(output: &str) -> String {
|
|
filter_cargo_build_labeled(output, "build", 0)
|
|
}
|
|
|
|
fn filter_cargo_build_labeled(output: &str, label: &'static str, exit_code: i32) -> String {
|
|
let mut handler = CargoBuildHandler::with_label(label);
|
|
let mut blocks: Vec<Vec<String>> = Vec::new();
|
|
let mut current_block: Vec<String> = Vec::new();
|
|
let mut in_block = false;
|
|
|
|
for line in output.lines() {
|
|
if handler.should_skip(line) {
|
|
continue;
|
|
}
|
|
if handler.is_block_start(line) {
|
|
if in_block && !current_block.is_empty() {
|
|
blocks.push(std::mem::take(&mut current_block));
|
|
}
|
|
in_block = true;
|
|
current_block.push(line.to_string());
|
|
} else if in_block {
|
|
if handler.is_block_continuation(line, ¤t_block) {
|
|
current_block.push(line.to_string());
|
|
} else {
|
|
blocks.push(std::mem::take(&mut current_block));
|
|
in_block = false;
|
|
}
|
|
}
|
|
}
|
|
if !current_block.is_empty() {
|
|
blocks.push(current_block);
|
|
}
|
|
|
|
let json = extract_json_diagnostics(output);
|
|
let (errors, warnings) = merge_diag_counts(handler.error_count, handler.warnings, &json);
|
|
|
|
if errors == 0 && warnings == 0 && exit_code == 0 {
|
|
return cargo_build_success_line(handler.compiled, handler.finished_line.as_deref(), label);
|
|
}
|
|
|
|
let mut result =
|
|
cargo_build_failure_summary(handler.compiled, errors, warnings, &json, label, exit_code);
|
|
const MAX_CHECK_BLOCKS: usize = CAP_ERRORS;
|
|
for (i, blk) in blocks.iter().enumerate().take(MAX_CHECK_BLOCKS) {
|
|
result.push_str(&blk.join("\n"));
|
|
result.push('\n');
|
|
if i < blocks.len() - 1 {
|
|
result.push('\n');
|
|
}
|
|
}
|
|
if blocks.len() > MAX_CHECK_BLOCKS {
|
|
result.push_str(&format!(
|
|
"\n… +{} more issues\n",
|
|
blocks.len() - MAX_CHECK_BLOCKS
|
|
));
|
|
let all_blocks: String = blocks
|
|
.iter()
|
|
.map(|b| b.join("\n"))
|
|
.collect::<Vec<_>>()
|
|
.join("\n\n");
|
|
if let Some(hint) = crate::core::tee::force_tee_hint(&all_blocks, "cargo-check-issues") {
|
|
result.push_str(&format!(" {}\n", hint));
|
|
}
|
|
}
|
|
result.trim().to_string()
|
|
}
|
|
|
|
/// Aggregated test results for compact display
|
|
#[derive(Debug, Default, Clone)]
|
|
struct AggregatedTestResult {
|
|
passed: usize,
|
|
failed: usize,
|
|
ignored: usize,
|
|
measured: usize,
|
|
filtered_out: usize,
|
|
suites: usize,
|
|
duration_secs: f64,
|
|
has_duration: bool,
|
|
}
|
|
|
|
impl AggregatedTestResult {
|
|
/// Parse a test result summary line
|
|
/// Format: "test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s"
|
|
fn parse_line(line: &str) -> Option<Self> {
|
|
static RE: OnceLock<regex::Regex> = OnceLock::new();
|
|
let re = RE.get_or_init(|| {
|
|
regex::Regex::new(
|
|
r"test result: (\w+)\.\s+(\d+) passed;\s+(\d+) failed;\s+(\d+) ignored;\s+(\d+) measured;\s+(\d+) filtered out(?:;\s+finished in ([\d.]+)s)?"
|
|
).unwrap()
|
|
});
|
|
|
|
let caps = re.captures(line)?;
|
|
let status = caps.get(1)?.as_str();
|
|
|
|
// Only aggregate if status is "ok" (all tests passed)
|
|
if status != "ok" {
|
|
return None;
|
|
}
|
|
|
|
let passed = caps.get(2)?.as_str().parse().ok()?;
|
|
let failed = caps.get(3)?.as_str().parse().ok()?;
|
|
let ignored = caps.get(4)?.as_str().parse().ok()?;
|
|
let measured = caps.get(5)?.as_str().parse().ok()?;
|
|
let filtered_out = caps.get(6)?.as_str().parse().ok()?;
|
|
|
|
let (duration_secs, has_duration) = if let Some(duration_match) = caps.get(7) {
|
|
(duration_match.as_str().parse().unwrap_or(0.0), true)
|
|
} else {
|
|
(0.0, false)
|
|
};
|
|
|
|
Some(Self {
|
|
passed,
|
|
failed,
|
|
ignored,
|
|
measured,
|
|
filtered_out,
|
|
suites: 1,
|
|
duration_secs,
|
|
has_duration,
|
|
})
|
|
}
|
|
|
|
/// Merge another test result into this one
|
|
fn merge(&mut self, other: &Self) {
|
|
self.passed += other.passed;
|
|
self.failed += other.failed;
|
|
self.ignored += other.ignored;
|
|
self.measured += other.measured;
|
|
self.filtered_out += other.filtered_out;
|
|
self.suites += other.suites;
|
|
self.duration_secs += other.duration_secs;
|
|
self.has_duration = self.has_duration && other.has_duration;
|
|
}
|
|
|
|
/// Format as compact single line
|
|
fn format_compact(&self) -> String {
|
|
let mut parts = vec![format!("{} passed", self.passed)];
|
|
|
|
if self.ignored > 0 {
|
|
parts.push(format!("{} ignored", self.ignored));
|
|
}
|
|
if self.filtered_out > 0 {
|
|
parts.push(format!("{} filtered out", self.filtered_out));
|
|
}
|
|
|
|
let counts = parts.join(", ");
|
|
|
|
let suite_text = if self.suites == 1 {
|
|
"1 suite".to_string()
|
|
} else {
|
|
format!("{} suites", self.suites)
|
|
};
|
|
|
|
if self.has_duration {
|
|
format!(
|
|
"cargo test: {} ({}, {:.2}s)",
|
|
counts, suite_text, self.duration_secs
|
|
)
|
|
} else {
|
|
format!("cargo test: {} ({})", counts, suite_text)
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn filter_cargo_test(output: &str) -> String {
|
|
let mut failures: Vec<String> = Vec::new();
|
|
let mut summary_lines: Vec<String> = Vec::new();
|
|
let mut in_failure_section = false;
|
|
let mut current_failure = Vec::new();
|
|
|
|
for line in output.lines() {
|
|
// Skip compilation lines
|
|
if line.trim_start().starts_with("Compiling")
|
|
|| line.trim_start().starts_with("Downloading")
|
|
|| line.trim_start().starts_with("Downloaded")
|
|
|| line.trim_start().starts_with("Finished")
|
|
{
|
|
continue;
|
|
}
|
|
|
|
// Skip "running N tests" and individual "test ... ok" lines
|
|
if line.starts_with("running ") || (line.starts_with("test ") && line.ends_with("... ok")) {
|
|
continue;
|
|
}
|
|
|
|
// Detect failures section
|
|
if line == "failures:" {
|
|
in_failure_section = true;
|
|
continue;
|
|
}
|
|
|
|
if in_failure_section {
|
|
if line.starts_with("test result:") {
|
|
in_failure_section = false;
|
|
summary_lines.push(line.to_string());
|
|
} else if line.starts_with(" ") || line.starts_with("---- ") {
|
|
current_failure.push(line.to_string());
|
|
} else if line.trim().is_empty() && !current_failure.is_empty() {
|
|
failures.push(current_failure.join("\n"));
|
|
current_failure.clear();
|
|
} else if !line.trim().is_empty() {
|
|
current_failure.push(line.to_string());
|
|
}
|
|
}
|
|
|
|
// Capture test result summary
|
|
if !in_failure_section && line.starts_with("test result:") {
|
|
summary_lines.push(line.to_string());
|
|
}
|
|
}
|
|
|
|
if !current_failure.is_empty() {
|
|
failures.push(current_failure.join("\n"));
|
|
}
|
|
|
|
let mut result = String::new();
|
|
|
|
if failures.is_empty() && !summary_lines.is_empty() {
|
|
// All passed - try to aggregate
|
|
let mut aggregated: Option<AggregatedTestResult> = None;
|
|
let mut all_parsed = true;
|
|
|
|
for line in &summary_lines {
|
|
if let Some(parsed) = AggregatedTestResult::parse_line(line) {
|
|
if let Some(ref mut agg) = aggregated {
|
|
agg.merge(&parsed);
|
|
} else {
|
|
aggregated = Some(parsed);
|
|
}
|
|
} else {
|
|
all_parsed = false;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// If all lines parsed successfully and we have at least one suite, return compact format
|
|
if all_parsed {
|
|
if let Some(agg) = aggregated {
|
|
if agg.suites > 0 {
|
|
return agg.format_compact();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fallback: use original behavior if regex failed
|
|
for line in &summary_lines {
|
|
result.push_str(&format!("{}\n", line));
|
|
}
|
|
return result.trim().to_string();
|
|
}
|
|
|
|
if !failures.is_empty() {
|
|
result.push_str(&format!("FAILURES ({}):\n", failures.len()));
|
|
const MAX_FAILURES: usize = CAP_WARNINGS;
|
|
for (i, failure) in failures.iter().enumerate().take(MAX_FAILURES) {
|
|
result.push_str(&format!("{}. {}\n", i + 1, truncate(failure, 200)));
|
|
}
|
|
if failures.len() > MAX_FAILURES {
|
|
result.push_str(&format!("\n… +{} more failures\n", failures.len() - MAX_FAILURES));
|
|
let all_failures = failures.join("\n\n");
|
|
if let Some(hint) =
|
|
crate::core::tee::force_tee_hint(&all_failures, "cargo-test-failures")
|
|
{
|
|
result.push_str(&format!(" {}\n", hint));
|
|
}
|
|
}
|
|
result.push('\n');
|
|
}
|
|
|
|
for line in &summary_lines {
|
|
result.push_str(&format!("{}\n", line));
|
|
}
|
|
|
|
if result.trim().is_empty() {
|
|
let json = extract_json_diagnostics(output);
|
|
let has_compile_errors = !json.errors.is_empty()
|
|
|| output.lines().any(|line| {
|
|
let trimmed = line.trim_start();
|
|
trimmed.starts_with("error[") || trimmed.starts_with("error:")
|
|
});
|
|
|
|
if has_compile_errors {
|
|
let build_filtered = filter_cargo_build_labeled(output, "test", 0);
|
|
if build_filtered.contains("cargo test:") {
|
|
return build_filtered;
|
|
}
|
|
}
|
|
|
|
// Fallback: show last meaningful lines
|
|
let meaningful: Vec<&str> = output
|
|
.lines()
|
|
.filter(|l| !l.trim().is_empty() && !l.trim_start().starts_with("Compiling"))
|
|
.collect();
|
|
for line in meaningful.iter().rev().take(5).rev() {
|
|
result.push_str(&format!("{}\n", line));
|
|
}
|
|
}
|
|
|
|
result.trim().to_string()
|
|
}
|
|
|
|
/// Filter cargo clippy output - show full error blocks, group warnings by lint rule
|
|
fn filter_cargo_clippy(output: &str) -> String {
|
|
let mut by_rule: HashMap<String, Vec<String>> = HashMap::new();
|
|
let mut error_count = 0;
|
|
let mut warning_count = 0;
|
|
// Each entry is a full multi-line error block (headline + location + code context)
|
|
let mut error_blocks: Vec<Vec<String>> = Vec::new();
|
|
|
|
let mut current_rule = String::new();
|
|
let mut in_error = false;
|
|
let mut current_block: Vec<String> = Vec::new();
|
|
|
|
for line in output.lines() {
|
|
// Skip compilation progress lines
|
|
if line.trim_start().starts_with("Compiling")
|
|
|| line.trim_start().starts_with("Checking")
|
|
|| line.trim_start().starts_with("Downloading")
|
|
|| line.trim_start().starts_with("Downloaded")
|
|
|| line.trim_start().starts_with("Finished")
|
|
{
|
|
if in_error && !current_block.is_empty() {
|
|
error_blocks.push(current_block.clone());
|
|
current_block.clear();
|
|
in_error = false;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Skip noise: summary counts and abort lines
|
|
if (line.contains("generated") && line.contains("warning"))
|
|
|| line.contains("aborting due to")
|
|
|| line.contains("could not compile")
|
|
{
|
|
continue;
|
|
}
|
|
|
|
let is_error_line = line.starts_with("error:") || line.starts_with("error[");
|
|
let is_warning_line = line.starts_with("warning:") || line.starts_with("warning[");
|
|
|
|
if is_error_line || is_warning_line {
|
|
// Flush any in-progress error block before starting a new diagnostic
|
|
if in_error && !current_block.is_empty() {
|
|
error_blocks.push(current_block.clone());
|
|
current_block.clear();
|
|
}
|
|
in_error = false;
|
|
|
|
if is_error_line {
|
|
error_count += 1;
|
|
in_error = true;
|
|
current_block.push(line.to_string());
|
|
} else {
|
|
warning_count += 1;
|
|
}
|
|
|
|
// Extract rule/error-code from brackets for warning grouping
|
|
current_rule = if let Some(bracket_start) = line.rfind('[') {
|
|
if let Some(bracket_end) = line.rfind(']') {
|
|
line[bracket_start + 1..bracket_end].to_string()
|
|
} else {
|
|
line.to_string()
|
|
}
|
|
} else {
|
|
let prefix = if is_error_line {
|
|
"error: "
|
|
} else {
|
|
"warning: "
|
|
};
|
|
line.strip_prefix(prefix).unwrap_or(line).to_string()
|
|
};
|
|
} else if line.trim_start().starts_with("--> ") {
|
|
let location = line.trim_start().trim_start_matches("--> ").to_string();
|
|
if !current_rule.is_empty() {
|
|
by_rule
|
|
.entry(current_rule.clone())
|
|
.or_default()
|
|
.push(location);
|
|
}
|
|
if in_error {
|
|
current_block.push(line.to_string());
|
|
}
|
|
} else if in_error {
|
|
if line.trim().is_empty() {
|
|
// Blank line terminates the error block
|
|
if !current_block.is_empty() {
|
|
error_blocks.push(current_block.clone());
|
|
current_block.clear();
|
|
}
|
|
in_error = false;
|
|
} else if current_block.len() < 15 {
|
|
// Collect code-context lines (|, ^, = note:, help:, etc.)
|
|
current_block.push(line.to_string());
|
|
}
|
|
}
|
|
}
|
|
|
|
// Flush final error block
|
|
if in_error && !current_block.is_empty() {
|
|
error_blocks.push(current_block);
|
|
}
|
|
|
|
if error_count == 0 && warning_count == 0 {
|
|
return "cargo clippy: No issues found".to_string();
|
|
}
|
|
|
|
let mut result = String::new();
|
|
result.push_str(&format!(
|
|
"cargo clippy: {} errors, {} warnings\n",
|
|
error_count, warning_count
|
|
));
|
|
|
|
// Show full error blocks so developers can see what needs fixing
|
|
if !error_blocks.is_empty() {
|
|
const MAX_CLIPPY_ERRORS: usize = CAP_WARNINGS;
|
|
result.push_str("\nErrors:\n");
|
|
for block in error_blocks.iter().take(MAX_CLIPPY_ERRORS) {
|
|
for block_line in block {
|
|
result.push_str(&format!(" {}\n", truncate(block_line, 160)));
|
|
}
|
|
result.push('\n');
|
|
}
|
|
if error_blocks.len() > MAX_CLIPPY_ERRORS {
|
|
result.push_str(&format!(
|
|
" … +{} more errors\n",
|
|
error_blocks.len() - MAX_CLIPPY_ERRORS
|
|
));
|
|
let all_blocks: String = error_blocks
|
|
.iter()
|
|
.map(|b| b.join("\n"))
|
|
.collect::<Vec<_>>()
|
|
.join("\n\n");
|
|
if let Some(hint) =
|
|
crate::core::tee::force_tee_hint(&all_blocks, "cargo-clippy-errors")
|
|
{
|
|
result.push_str(&format!(" {}\n", hint));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sort warning rules by frequency
|
|
let mut rule_counts: Vec<_> = by_rule.iter().collect();
|
|
rule_counts.sort_by_key(|b| std::cmp::Reverse(b.1.len()));
|
|
|
|
const MAX_RULES: usize = CAP_LIST;
|
|
for (rule, locations) in rule_counts.iter().take(MAX_RULES) {
|
|
result.push_str(&format!(" {} ({}x)\n", rule, locations.len()));
|
|
for loc in locations.iter().take(3) {
|
|
result.push_str(&format!(" {}\n", loc));
|
|
}
|
|
if locations.len() > 3 {
|
|
result.push_str(&format!(" … +{} more\n", locations.len() - 3));
|
|
}
|
|
}
|
|
|
|
if by_rule.len() > MAX_RULES {
|
|
result.push_str(&format!("\n… +{} more rules\n", by_rule.len() - MAX_RULES));
|
|
let all_rules = rule_counts
|
|
.iter()
|
|
.map(|(rule, locs)| format!("{} ({}x)", rule, locs.len()))
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
if let Some(hint) =
|
|
crate::core::tee::force_tee_tail_hint(&all_rules, "cargo-clippy-rules", MAX_RULES + 1)
|
|
{
|
|
result.push_str(&format!(" {}\n", hint));
|
|
}
|
|
}
|
|
|
|
result.trim().to_string()
|
|
}
|
|
|
|
fn filter_cargo_clippy_json(output: &str, exit_code: i32) -> String {
|
|
let json = extract_json_diagnostics(output);
|
|
if json.errors.is_empty() && json.warnings.is_empty() && exit_code == 0 {
|
|
return "cargo clippy: No issues found".to_string();
|
|
}
|
|
filter_cargo_build_labeled(output, "clippy", exit_code)
|
|
}
|
|
|
|
pub fn run_passthrough(args: &[OsString], verbose: u8) -> Result<i32> {
|
|
crate::core::runner::run_passthrough("cargo", args, verbose)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::core::args_utils::restore_double_dash_with_raw;
|
|
|
|
#[test]
|
|
fn test_restore_double_dash_with_separator() {
|
|
// rtk cargo test -- --nocapture → clap gives ["--nocapture"]
|
|
let args: Vec<String> = vec!["--nocapture".into()];
|
|
let raw = vec![
|
|
"rtk".into(),
|
|
"cargo".into(),
|
|
"test".into(),
|
|
"--".into(),
|
|
"--nocapture".into(),
|
|
];
|
|
let result = restore_double_dash_with_raw(&args, &raw);
|
|
assert_eq!(result, vec!["--", "--nocapture"]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_restore_double_dash_with_test_name() {
|
|
// rtk cargo test my_test -- --nocapture → clap gives ["my_test", "--nocapture"]
|
|
let args: Vec<String> = vec!["my_test".into(), "--nocapture".into()];
|
|
let raw = vec![
|
|
"rtk".into(),
|
|
"cargo".into(),
|
|
"test".into(),
|
|
"my_test".into(),
|
|
"--".into(),
|
|
"--nocapture".into(),
|
|
];
|
|
let result = restore_double_dash_with_raw(&args, &raw);
|
|
assert_eq!(result, vec!["my_test", "--", "--nocapture"]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_restore_double_dash_without_separator() {
|
|
// rtk cargo test my_test → no --, args unchanged
|
|
let args: Vec<String> = vec!["my_test".into()];
|
|
let raw = vec![
|
|
"rtk".into(),
|
|
"cargo".into(),
|
|
"test".into(),
|
|
"my_test".into(),
|
|
];
|
|
let result = restore_double_dash_with_raw(&args, &raw);
|
|
assert_eq!(result, vec!["my_test"]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_restore_double_dash_empty_args() {
|
|
let args: Vec<String> = vec![];
|
|
let raw = vec!["rtk".into(), "cargo".into(), "test".into()];
|
|
let result = restore_double_dash_with_raw(&args, &raw);
|
|
assert!(result.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_restore_double_dash_clippy() {
|
|
// rtk cargo clippy -- -D warnings → clap gives ["-D", "warnings"]
|
|
let args: Vec<String> = vec!["-D".into(), "warnings".into()];
|
|
let raw = vec![
|
|
"rtk".into(),
|
|
"cargo".into(),
|
|
"clippy".into(),
|
|
"--".into(),
|
|
"-D".into(),
|
|
"warnings".into(),
|
|
];
|
|
let result = restore_double_dash_with_raw(&args, &raw);
|
|
assert_eq!(result, vec!["--", "-D", "warnings"]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_restore_double_dash_clippy_with_package_flags() {
|
|
// rtk cargo clippy -p my-service -p my-crate -- -D warnings
|
|
// Clap with trailing_var_arg preserves "--" when args precede it
|
|
// → clap gives ["-p", "my-service", "-p", "my-crate", "--", "-D", "warnings"]
|
|
let args: Vec<String> = vec![
|
|
"-p".into(),
|
|
"my-service".into(),
|
|
"-p".into(),
|
|
"my-crate".into(),
|
|
"--".into(),
|
|
"-D".into(),
|
|
"warnings".into(),
|
|
];
|
|
let raw = vec![
|
|
"rtk".into(),
|
|
"cargo".into(),
|
|
"clippy".into(),
|
|
"-p".into(),
|
|
"my-service".into(),
|
|
"-p".into(),
|
|
"my-crate".into(),
|
|
"--".into(),
|
|
"-D".into(),
|
|
"warnings".into(),
|
|
];
|
|
let result = restore_double_dash_with_raw(&args, &raw);
|
|
// Should NOT double the "--"
|
|
assert_eq!(
|
|
result,
|
|
vec!["-p", "my-service", "-p", "my-crate", "--", "-D", "warnings"]
|
|
);
|
|
// Verify only one "--" exists
|
|
assert_eq!(result.iter().filter(|a| *a == "--").count(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_filter_cargo_build_success() {
|
|
let output = r#" Compiling libc v0.2.153
|
|
Compiling cfg-if v1.0.0
|
|
Compiling rtk v0.5.0
|
|
Finished dev [unoptimized + debuginfo] target(s) in 15.23s
|
|
"#;
|
|
let result = filter_cargo_build(output);
|
|
assert!(result.contains("cargo build"));
|
|
assert!(result.contains("3 crates compiled"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_filter_cargo_build_errors() {
|
|
let output = r#" Compiling rtk v0.5.0
|
|
error[E0308]: mismatched types
|
|
--> src/main.rs:10:5
|
|
|
|
|
10| "hello"
|
|
| ^^^^^^^ expected `i32`, found `&str`
|
|
|
|
error: aborting due to 1 previous error
|
|
"#;
|
|
let result = filter_cargo_build(output);
|
|
assert!(result.contains("1 errors"));
|
|
assert!(result.contains("E0308"));
|
|
assert!(result.contains("mismatched types"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_filter_cargo_test_all_pass() {
|
|
let output = r#" Compiling rtk v0.5.0
|
|
Finished test [unoptimized + debuginfo] target(s) in 2.53s
|
|
Running target/debug/deps/rtk-abc123
|
|
|
|
running 15 tests
|
|
test utils::tests::test_truncate_short_string ... ok
|
|
test utils::tests::test_truncate_long_string ... ok
|
|
test utils::tests::test_strip_ansi_simple ... ok
|
|
|
|
test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
|
|
"#;
|
|
let result = filter_cargo_test(output);
|
|
assert!(
|
|
result.contains("cargo test: 15 passed (1 suite, 0.01s)"),
|
|
"Expected compact format, got: {}",
|
|
result
|
|
);
|
|
assert!(!result.contains("Compiling"));
|
|
assert!(!result.contains("test utils"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_filter_cargo_test_failures() {
|
|
let output = r#"running 5 tests
|
|
test foo::test_a ... ok
|
|
test foo::test_b ... FAILED
|
|
test foo::test_c ... ok
|
|
|
|
failures:
|
|
|
|
---- foo::test_b stdout ----
|
|
thread 'foo::test_b' panicked at 'assert_eq!(1, 2)'
|
|
|
|
failures:
|
|
foo::test_b
|
|
|
|
test result: FAILED. 4 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out
|
|
"#;
|
|
let result = filter_cargo_test(output);
|
|
assert!(result.contains("FAILURES"));
|
|
assert!(result.contains("test_b"));
|
|
assert!(result.contains("test result:"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_filter_cargo_test_multi_suite_all_pass() {
|
|
let output = r#" Compiling rtk v0.5.0
|
|
Finished test [unoptimized + debuginfo] target(s) in 2.53s
|
|
Running unittests src/lib.rs (target/debug/deps/rtk-abc123)
|
|
|
|
running 50 tests
|
|
test result: ok. 50 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.45s
|
|
|
|
Running unittests src/main.rs (target/debug/deps/rtk-def456)
|
|
|
|
running 30 tests
|
|
test result: ok. 30 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.30s
|
|
|
|
Running tests/integration.rs (target/debug/deps/integration-ghi789)
|
|
|
|
running 25 tests
|
|
test result: ok. 25 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.25s
|
|
|
|
Doc-tests rtk
|
|
|
|
running 32 tests
|
|
test result: ok. 32 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.45s
|
|
"#;
|
|
let result = filter_cargo_test(output);
|
|
assert!(
|
|
result.contains("cargo test: 137 passed (4 suites, 1.45s)"),
|
|
"Expected aggregated format, got: {}",
|
|
result
|
|
);
|
|
assert!(!result.contains("running"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_filter_cargo_test_multi_suite_with_failures() {
|
|
let output = r#" Running unittests src/lib.rs
|
|
|
|
running 20 tests
|
|
test result: ok. 20 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.10s
|
|
|
|
Running unittests src/main.rs
|
|
|
|
running 15 tests
|
|
test foo::test_bad ... FAILED
|
|
|
|
failures:
|
|
|
|
---- foo::test_bad stdout ----
|
|
thread panicked at 'assertion failed'
|
|
|
|
test result: FAILED. 14 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.05s
|
|
|
|
Running tests/integration.rs
|
|
|
|
running 10 tests
|
|
test result: ok. 10 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s
|
|
"#;
|
|
let result = filter_cargo_test(output);
|
|
// Should NOT aggregate when there are failures
|
|
assert!(result.contains("FAILURES"), "got: {}", result);
|
|
assert!(result.contains("test_bad"), "got: {}", result);
|
|
assert!(result.contains("test result:"), "got: {}", result);
|
|
// Should show individual summaries
|
|
assert!(result.contains("20 passed"), "got: {}", result);
|
|
assert!(result.contains("14 passed"), "got: {}", result);
|
|
assert!(result.contains("10 passed"), "got: {}", result);
|
|
}
|
|
|
|
#[test]
|
|
fn test_filter_cargo_test_all_suites_zero_tests() {
|
|
let output = r#" Running unittests src/empty1.rs
|
|
|
|
running 0 tests
|
|
|
|
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
|
|
|
|
Running unittests src/empty2.rs
|
|
|
|
running 0 tests
|
|
|
|
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
|
|
|
|
Running tests/empty3.rs
|
|
|
|
running 0 tests
|
|
|
|
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
|
|
"#;
|
|
let result = filter_cargo_test(output);
|
|
assert!(
|
|
result.contains("cargo test: 0 passed (3 suites, 0.00s)"),
|
|
"Expected compact format for zero tests, got: {}",
|
|
result
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_filter_cargo_test_with_ignored_and_filtered() {
|
|
let output = r#" Running unittests src/lib.rs
|
|
|
|
running 50 tests
|
|
test result: ok. 45 passed; 0 failed; 3 ignored; 0 measured; 2 filtered out; finished in 0.50s
|
|
|
|
Running tests/integration.rs
|
|
|
|
running 20 tests
|
|
test result: ok. 18 passed; 0 failed; 2 ignored; 0 measured; 0 filtered out; finished in 0.20s
|
|
"#;
|
|
let result = filter_cargo_test(output);
|
|
assert!(
|
|
result.contains("cargo test: 63 passed, 5 ignored, 2 filtered out (2 suites, 0.70s)"),
|
|
"Expected compact format with ignored and filtered, got: {}",
|
|
result
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_filter_cargo_test_single_suite_compact() {
|
|
let output = r#" Running unittests src/main.rs
|
|
|
|
running 15 tests
|
|
test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
|
|
"#;
|
|
let result = filter_cargo_test(output);
|
|
assert!(
|
|
result.contains("cargo test: 15 passed (1 suite, 0.01s)"),
|
|
"Expected singular 'suite', got: {}",
|
|
result
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_filter_cargo_test_regex_fallback() {
|
|
let output = r#" Running unittests src/main.rs
|
|
|
|
running 15 tests
|
|
test result: MALFORMED LINE WITHOUT PROPER FORMAT
|
|
"#;
|
|
let result = filter_cargo_test(output);
|
|
// Should fallback to original behavior (show line without checkmark)
|
|
assert!(
|
|
result.contains("test result: MALFORMED"),
|
|
"Expected fallback format, got: {}",
|
|
result
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_filter_cargo_test_compile_error_preserves_error_header() {
|
|
let output = r#" Compiling rtk v0.31.0 (/workspace/projects/rtk)
|
|
error[E0425]: cannot find value `missing_symbol` in this scope
|
|
--> tests/repro_compile_fail.rs:3:13
|
|
|
|
|
3 | let _ = missing_symbol;
|
|
| ^^^^^^^^^^^^^^ not found in this scope
|
|
|
|
For more information about this error, try `rustc --explain E0425`.
|
|
error: could not compile `rtk` (test "repro_compile_fail") due to 1 previous error
|
|
"#;
|
|
let result = filter_cargo_test(output);
|
|
assert!(result.contains("cargo test: 1 errors, 0 warnings (1 crates)"));
|
|
assert!(result.contains("error[E0425]"), "got: {}", result);
|
|
assert!(
|
|
result.contains("--> tests/repro_compile_fail.rs:3:13"),
|
|
"got: {}",
|
|
result
|
|
);
|
|
assert!(!result.starts_with('|'), "got: {}", result);
|
|
}
|
|
|
|
#[test]
|
|
fn test_filter_cargo_clippy_clean() {
|
|
let output = r#" Checking rtk v0.5.0
|
|
Finished dev [unoptimized + debuginfo] target(s) in 1.53s
|
|
"#;
|
|
let result = filter_cargo_clippy(output);
|
|
assert!(result.contains("cargo clippy: No issues found"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_filter_cargo_clippy_warnings() {
|
|
let output = r#" Checking rtk v0.5.0
|
|
warning: unused variable: `x` [unused_variables]
|
|
--> src/main.rs:10:9
|
|
|
|
|
10| let x = 5;
|
|
| ^ help: if this is intentional, prefix it with an underscore: `_x`
|
|
|
|
warning: this function has too many arguments [clippy::too_many_arguments]
|
|
--> src/git.rs:16:1
|
|
|
|
|
16| pub fn run(a: i32, b: i32, c: i32, d: i32, e: i32, f: i32, g: i32, h: i32) {}
|
|
|
|
|
|
|
warning: `rtk` (bin) generated 2 warnings
|
|
Finished dev [unoptimized + debuginfo] target(s) in 1.53s
|
|
"#;
|
|
let result = filter_cargo_clippy(output);
|
|
assert!(result.contains("0 errors, 2 warnings"));
|
|
assert!(result.contains("unused_variables"));
|
|
assert!(result.contains("clippy::too_many_arguments"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_filter_cargo_clippy_includes_error_details() {
|
|
let output = r#" Checking rtk v0.5.0
|
|
error: struct literals are not allowed here
|
|
warning: unused variable: `x` [unused_variables]
|
|
Finished dev [unoptimized + debuginfo] target(s) in 1.53s
|
|
"#;
|
|
let result = filter_cargo_clippy(output);
|
|
assert!(result.contains("cargo clippy: 1 errors, 1 warnings"));
|
|
assert!(result.contains("Errors:"));
|
|
assert!(result.contains("struct literals are not allowed here"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_filter_cargo_clippy_shows_full_error_block() {
|
|
// Full multi-line error block must be shown so the developer can debug
|
|
let output = r#" Checking rtk v0.5.0
|
|
error[E0308]: mismatched types
|
|
--> src/main.rs:10:5
|
|
|
|
|
9 | fn foo() -> i32 {
|
|
| --- expected `i32` because of return type
|
|
10| "hello"
|
|
| ^^^^^^^ expected `i32`, found `&str`
|
|
|
|
error: aborting due to 1 previous error
|
|
"#;
|
|
let result = filter_cargo_clippy(output);
|
|
assert!(
|
|
result.contains("cargo clippy: 1 errors, 0 warnings"),
|
|
"got: {}",
|
|
result
|
|
);
|
|
assert!(
|
|
result.contains("error[E0308]: mismatched types"),
|
|
"got: {}",
|
|
result
|
|
);
|
|
assert!(result.contains("src/main.rs:10:5"), "got: {}", result);
|
|
assert!(
|
|
result.contains("expected `i32`, found `&str`"),
|
|
"got: {}",
|
|
result
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_filter_cargo_clippy_multiple_errors_show_all_blocks() {
|
|
let output = r#"error[E0308]: mismatched types
|
|
--> src/foo.rs:5:3
|
|
|
|
error[E0425]: cannot find value `x`
|
|
--> src/bar.rs:12:9
|
|
|
|
error: aborting due to 2 previous errors
|
|
"#;
|
|
let result = filter_cargo_clippy(output);
|
|
assert!(result.contains("2 errors"), "got: {}", result);
|
|
assert!(result.contains("src/foo.rs:5:3"), "got: {}", result);
|
|
assert!(result.contains("src/bar.rs:12:9"), "got: {}", result);
|
|
}
|
|
|
|
#[test]
|
|
fn test_filter_cargo_install_success() {
|
|
let output = r#" Installing rtk v0.11.0
|
|
Downloading crates ...
|
|
Downloaded anyhow v1.0.80
|
|
Downloaded clap v4.5.0
|
|
Compiling libc v0.2.153
|
|
Compiling cfg-if v1.0.0
|
|
Compiling anyhow v1.0.80
|
|
Compiling clap v4.5.0
|
|
Compiling rtk v0.11.0
|
|
Finished `release` profile [optimized] target(s) in 45.23s
|
|
Replacing /Users/user/.cargo/bin/rtk
|
|
Replaced package `rtk v0.9.4` with `rtk v0.11.0` (/Users/user/.cargo/bin/rtk)
|
|
"#;
|
|
let result = filter_cargo_install(output);
|
|
assert!(result.contains("cargo install"), "got: {}", result);
|
|
assert!(result.contains("rtk v0.11.0"), "got: {}", result);
|
|
assert!(result.contains("5 deps compiled"), "got: {}", result);
|
|
assert!(result.contains("Replaced"), "got: {}", result);
|
|
assert!(!result.contains("Compiling"), "got: {}", result);
|
|
assert!(!result.contains("Downloading"), "got: {}", result);
|
|
}
|
|
|
|
#[test]
|
|
fn test_filter_cargo_install_replace() {
|
|
let output = r#" Installing rtk v0.11.0
|
|
Compiling rtk v0.11.0
|
|
Finished `release` profile [optimized] target(s) in 10.0s
|
|
Replacing /Users/user/.cargo/bin/rtk
|
|
Replaced package `rtk v0.9.4` with `rtk v0.11.0` (/Users/user/.cargo/bin/rtk)
|
|
"#;
|
|
let result = filter_cargo_install(output);
|
|
assert!(result.contains("cargo install"), "got: {}", result);
|
|
assert!(result.contains("Replacing"), "got: {}", result);
|
|
assert!(result.contains("Replaced"), "got: {}", result);
|
|
}
|
|
|
|
#[test]
|
|
fn test_filter_cargo_install_error() {
|
|
let output = r#" Installing rtk v0.11.0
|
|
Compiling rtk v0.11.0
|
|
error[E0308]: mismatched types
|
|
--> src/main.rs:10:5
|
|
|
|
|
10| "hello"
|
|
| ^^^^^^^ expected `i32`, found `&str`
|
|
|
|
error: aborting due to 1 previous error
|
|
"#;
|
|
let result = filter_cargo_install(output);
|
|
assert!(result.contains("cargo install: 1 error"), "got: {}", result);
|
|
assert!(result.contains("E0308"), "got: {}", result);
|
|
assert!(result.contains("mismatched types"), "got: {}", result);
|
|
assert!(!result.contains("aborting"), "got: {}", result);
|
|
}
|
|
|
|
#[test]
|
|
fn test_filter_cargo_install_already_installed() {
|
|
let output = r#" Ignored package `rtk v0.11.0`, is already installed
|
|
"#;
|
|
let result = filter_cargo_install(output);
|
|
assert!(result.contains("already installed"), "got: {}", result);
|
|
assert!(result.contains("rtk v0.11.0"), "got: {}", result);
|
|
}
|
|
|
|
#[test]
|
|
fn test_filter_cargo_install_up_to_date() {
|
|
let output = r#" Ignored package `cargo-deb v2.1.0 (/Users/user/cargo-deb)`, is already installed
|
|
"#;
|
|
let result = filter_cargo_install(output);
|
|
assert!(result.contains("already installed"), "got: {}", result);
|
|
assert!(result.contains("cargo-deb v2.1.0"), "got: {}", result);
|
|
}
|
|
|
|
#[test]
|
|
fn test_filter_cargo_install_empty_output() {
|
|
let result = filter_cargo_install("");
|
|
assert!(result.contains("cargo install"), "got: {}", result);
|
|
assert!(result.contains("0 deps compiled"), "got: {}", result);
|
|
}
|
|
|
|
#[test]
|
|
fn test_filter_cargo_install_path_warning() {
|
|
let output = r#" Installing rtk v0.11.0
|
|
Compiling rtk v0.11.0
|
|
Finished `release` profile [optimized] target(s) in 10.0s
|
|
Replacing /Users/user/.cargo/bin/rtk
|
|
Replaced package `rtk v0.9.4` with `rtk v0.11.0` (/Users/user/.cargo/bin/rtk)
|
|
warning: be sure to add `/Users/user/.cargo/bin` to your PATH
|
|
"#;
|
|
let result = filter_cargo_install(output);
|
|
assert!(result.contains("cargo install"), "got: {}", result);
|
|
assert!(
|
|
result.contains("be sure to add"),
|
|
"PATH warning should be kept: {}",
|
|
result
|
|
);
|
|
assert!(result.contains("Replaced"), "got: {}", result);
|
|
}
|
|
|
|
#[test]
|
|
fn test_filter_cargo_install_multiple_errors() {
|
|
let output = r#" Installing rtk v0.11.0
|
|
Compiling rtk v0.11.0
|
|
error[E0308]: mismatched types
|
|
--> src/main.rs:10:5
|
|
|
|
|
10| "hello"
|
|
| ^^^^^^^ expected `i32`, found `&str`
|
|
|
|
error[E0425]: cannot find value `foo`
|
|
--> src/lib.rs:20:9
|
|
|
|
|
20| foo
|
|
| ^^^ not found in this scope
|
|
|
|
error: aborting due to 2 previous errors
|
|
"#;
|
|
let result = filter_cargo_install(output);
|
|
assert!(
|
|
result.contains("2 errors"),
|
|
"should show 2 errors: {}",
|
|
result
|
|
);
|
|
assert!(result.contains("E0308"), "got: {}", result);
|
|
assert!(result.contains("E0425"), "got: {}", result);
|
|
assert!(!result.contains("aborting"), "got: {}", result);
|
|
}
|
|
|
|
#[test]
|
|
fn test_filter_cargo_install_locking_and_blocking() {
|
|
let output = r#" Locking 45 packages to latest compatible versions
|
|
Blocking waiting for file lock on package cache
|
|
Downloading crates ...
|
|
Downloaded serde v1.0.200
|
|
Compiling serde v1.0.200
|
|
Compiling rtk v0.11.0
|
|
Finished `release` profile [optimized] target(s) in 30.0s
|
|
Installing rtk v0.11.0
|
|
"#;
|
|
let result = filter_cargo_install(output);
|
|
assert!(result.contains("cargo install"), "got: {}", result);
|
|
assert!(!result.contains("Locking"), "got: {}", result);
|
|
assert!(!result.contains("Blocking"), "got: {}", result);
|
|
assert!(!result.contains("Downloading"), "got: {}", result);
|
|
}
|
|
|
|
#[test]
|
|
fn test_filter_cargo_install_from_path() {
|
|
let output = r#" Installing /Users/user/projects/rtk
|
|
Compiling rtk v0.11.0
|
|
Finished `release` profile [optimized] target(s) in 10.0s
|
|
"#;
|
|
let result = filter_cargo_install(output);
|
|
// Path-based install: crate info not extracted from path
|
|
assert!(result.contains("cargo install"), "got: {}", result);
|
|
assert!(result.contains("1 deps compiled"), "got: {}", result);
|
|
}
|
|
|
|
#[test]
|
|
fn test_format_crate_info() {
|
|
assert_eq!(format_crate_info("rtk", "v0.11.0", ""), "rtk v0.11.0");
|
|
assert_eq!(format_crate_info("rtk", "", ""), "rtk");
|
|
assert_eq!(format_crate_info("", "", "package"), "package");
|
|
assert_eq!(format_crate_info("", "v0.1.0", "fallback"), "fallback");
|
|
}
|
|
|
|
#[test]
|
|
fn test_filter_cargo_nextest_all_pass() {
|
|
let output = r#" Compiling rtk v0.15.2
|
|
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.04s
|
|
────────────────────────────
|
|
Starting 301 tests across 1 binary
|
|
PASS [ 0.009s] (1/301) rtk::bin/rtk cargo_cmd::tests::test_one
|
|
PASS [ 0.008s] (2/301) rtk::bin/rtk cargo_cmd::tests::test_two
|
|
PASS [ 0.007s] (301/301) rtk::bin/rtk cargo_cmd::tests::test_last
|
|
────────────────────────────
|
|
Summary [ 0.192s] 301 tests run: 301 passed, 0 skipped
|
|
"#;
|
|
let result = filter_cargo_nextest(output);
|
|
assert_eq!(
|
|
result, "cargo nextest: 301 passed (1 binary, 0.192s)",
|
|
"got: {}",
|
|
result
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_filter_cargo_nextest_with_failures() {
|
|
let output = r#" Starting 4 tests across 1 binary (1 test skipped)
|
|
PASS [ 0.006s] (1/4) test-proj tests::passing_test
|
|
FAIL [ 0.006s] (2/4) test-proj tests::failing_test
|
|
|
|
stderr ───
|
|
|
|
thread 'tests::failing_test' panicked at src/lib.rs:15:9:
|
|
assertion `left == right` failed
|
|
left: 1
|
|
right: 2
|
|
|
|
Cancelling due to test failure: 2 tests still running
|
|
PASS [ 0.007s] (3/4) test-proj tests::another_passing
|
|
FAIL [ 0.006s] (4/4) test-proj tests::another_failing
|
|
|
|
stderr ───
|
|
|
|
thread 'tests::another_failing' panicked at src/lib.rs:20:9:
|
|
something went wrong
|
|
|
|
────────────────────────────
|
|
Summary [ 0.007s] 4 tests run: 2 passed, 2 failed, 1 skipped
|
|
FAIL [ 0.006s] (2/4) test-proj tests::failing_test
|
|
FAIL [ 0.006s] (4/4) test-proj tests::another_failing
|
|
error: test run failed
|
|
"#;
|
|
let result = filter_cargo_nextest(output);
|
|
assert!(
|
|
result.contains("tests::failing_test"),
|
|
"should contain first failure: {}",
|
|
result
|
|
);
|
|
assert!(
|
|
result.contains("tests::another_failing"),
|
|
"should contain second failure: {}",
|
|
result
|
|
);
|
|
assert!(
|
|
result.contains("panicked"),
|
|
"should contain stderr detail: {}",
|
|
result
|
|
);
|
|
assert!(
|
|
result.contains("2 passed, 2 failed, 1 skipped"),
|
|
"should contain summary: {}",
|
|
result
|
|
);
|
|
assert!(
|
|
!result.contains("PASS"),
|
|
"should not contain PASS lines: {}",
|
|
result
|
|
);
|
|
// Post-summary FAIL recaps must not create duplicate FAIL header entries
|
|
// (test names may appear in both header and stderr body naturally)
|
|
assert_eq!(
|
|
result.matches("FAIL [").count(),
|
|
2,
|
|
"should have exactly 2 FAIL headers (no post-summary duplicates): {}",
|
|
result
|
|
);
|
|
assert!(
|
|
!result.contains("error: test run failed"),
|
|
"should not contain post-summary error line: {}",
|
|
result
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_filter_cargo_nextest_with_skipped() {
|
|
let output = r#" Starting 50 tests across 2 binaries (3 tests skipped)
|
|
PASS [ 0.010s] (1/50) rtk::bin/rtk test_one
|
|
PASS [ 0.010s] (50/50) rtk::bin/rtk test_last
|
|
────────────────────────────
|
|
Summary [ 0.500s] 50 tests run: 50 passed, 3 skipped
|
|
"#;
|
|
let result = filter_cargo_nextest(output);
|
|
assert_eq!(
|
|
result, "cargo nextest: 50 passed, 3 skipped (2 binaries, 0.500s)",
|
|
"got: {}",
|
|
result
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_filter_cargo_nextest_single_failure_detail() {
|
|
let output = r#" Starting 2 tests across 1 binary
|
|
PASS [ 0.005s] (1/2) proj tests::good
|
|
FAIL [ 0.005s] (2/2) proj tests::bad
|
|
|
|
stderr ───
|
|
|
|
thread 'tests::bad' panicked at src/lib.rs:5:9:
|
|
assertion failed: false
|
|
|
|
────────────────────────────
|
|
Summary [ 0.010s] 2 tests run: 1 passed, 1 failed
|
|
FAIL [ 0.005s] (2/2) proj tests::bad
|
|
error: test run failed
|
|
"#;
|
|
let result = filter_cargo_nextest(output);
|
|
assert!(
|
|
result.contains("assertion failed: false"),
|
|
"should show panic message: {}",
|
|
result
|
|
);
|
|
assert!(
|
|
result.contains("1 passed, 1 failed"),
|
|
"should show summary: {}",
|
|
result
|
|
);
|
|
// Post-summary recap must not duplicate FAIL headers
|
|
assert_eq!(
|
|
result.matches("FAIL [").count(),
|
|
1,
|
|
"should have exactly 1 FAIL header (no post-summary duplicate): {}",
|
|
result
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_filter_cargo_nextest_multiple_binaries() {
|
|
let output = r#" Starting 100 tests across 5 binaries
|
|
PASS [ 0.010s] (100/100) test_last
|
|
────────────────────────────
|
|
Summary [ 1.234s] 100 tests run: 100 passed, 0 skipped
|
|
"#;
|
|
let result = filter_cargo_nextest(output);
|
|
assert_eq!(
|
|
result, "cargo nextest: 100 passed (5 binaries, 1.234s)",
|
|
"got: {}",
|
|
result
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_filter_cargo_nextest_compilation_stripped() {
|
|
let output = r#" Compiling serde v1.0.200
|
|
Compiling rtk v0.15.2
|
|
Downloading crates ...
|
|
Finished `test` profile [unoptimized + debuginfo] target(s) in 5.00s
|
|
────────────────────────────
|
|
Starting 10 tests across 1 binary
|
|
PASS [ 0.010s] (10/10) test_last
|
|
────────────────────────────
|
|
Summary [ 0.050s] 10 tests run: 10 passed, 0 skipped
|
|
"#;
|
|
let result = filter_cargo_nextest(output);
|
|
assert!(
|
|
!result.contains("Compiling"),
|
|
"should strip Compiling: {}",
|
|
result
|
|
);
|
|
assert!(
|
|
!result.contains("Downloading"),
|
|
"should strip Downloading: {}",
|
|
result
|
|
);
|
|
assert!(
|
|
!result.contains("Finished"),
|
|
"should strip Finished: {}",
|
|
result
|
|
);
|
|
assert!(
|
|
result.contains("cargo nextest: 10 passed"),
|
|
"got: {}",
|
|
result
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_filter_cargo_nextest_empty() {
|
|
let result = filter_cargo_nextest("");
|
|
assert!(result.is_empty(), "got: {}", result);
|
|
}
|
|
|
|
#[test]
|
|
fn test_filter_cargo_nextest_cancellation_notice() {
|
|
let output = r#" Starting 3 tests across 1 binary
|
|
FAIL [ 0.005s] (1/3) proj tests::bad
|
|
|
|
stderr ───
|
|
|
|
thread panicked at 'oops'
|
|
|
|
Cancelling due to test failure: 2 tests still running
|
|
────────────────────────────
|
|
Summary [ 0.010s] 3 tests run: 2 passed, 1 failed
|
|
FAIL [ 0.005s] (1/3) proj tests::bad
|
|
error: test run failed
|
|
"#;
|
|
let result = filter_cargo_nextest(output);
|
|
assert!(
|
|
result.contains("Cancelling due to test failure"),
|
|
"should include cancel notice: {}",
|
|
result
|
|
);
|
|
assert!(
|
|
result.contains("1 failed"),
|
|
"should show failure count: {}",
|
|
result
|
|
);
|
|
// Post-summary recap must not duplicate FAIL headers
|
|
assert_eq!(
|
|
result.matches("FAIL [").count(),
|
|
1,
|
|
"should have exactly 1 FAIL header (no post-summary duplicate): {}",
|
|
result
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_filter_cargo_nextest_summary_regex_fallback() {
|
|
let output = r#" Starting 5 tests across 1 binary
|
|
PASS [ 0.005s] (5/5) test_last
|
|
────────────────────────────
|
|
Summary MALFORMED LINE
|
|
"#;
|
|
let result = filter_cargo_nextest(output);
|
|
assert!(
|
|
result.contains("Summary MALFORMED"),
|
|
"should fall back to raw summary: {}",
|
|
result
|
|
);
|
|
}
|
|
|
|
// --- Streaming handler tests ---
|
|
|
|
use crate::core::stream::tests::run_block_filter;
|
|
|
|
#[test]
|
|
fn test_cargo_build_stream_success() {
|
|
let input = " Compiling libc v0.2.153\n Compiling cfg-if v1.0.0\n Compiling rtk v0.5.0\n Finished dev [unoptimized + debuginfo] target(s) in 15.23s\n";
|
|
let mut f = BlockStreamFilter::new(CargoBuildHandler::with_label("build"));
|
|
let result = run_block_filter(&mut f, input, 0);
|
|
assert!(result.contains("3 crates compiled"), "got: {}", result);
|
|
assert!(result.contains("Finished"), "got: {}", result);
|
|
assert!(!result.contains("Compiling"), "got: {}", result);
|
|
}
|
|
|
|
#[test]
|
|
fn test_cargo_build_stream_json_success() {
|
|
let input = concat!(
|
|
" Compiling demo v0.1.0 (/tmp/demo)\n",
|
|
r#"{"reason":"compiler-artifact","package_id":"demo 0.1.0","target":{"name":"demo"},"executable":"/tmp/demo/target/debug/demo"}"#,
|
|
"\n",
|
|
" Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.39s\n",
|
|
r#"{"reason":"build-finished","success":true}"#,
|
|
"\n",
|
|
);
|
|
let mut f = BlockStreamFilter::new(CargoBuildHandler::with_label("build"));
|
|
let result = run_block_filter(&mut f, input, 0);
|
|
assert!(result.contains("1 crates compiled"), "got: {}", result);
|
|
assert!(result.contains("Finished"), "got: {}", result);
|
|
assert!(!result.contains("error"), "got: {}", result);
|
|
assert!(!result.contains("Compiling"), "got: {}", result);
|
|
}
|
|
|
|
#[test]
|
|
fn test_cargo_build_stream_errors() {
|
|
let input = r#" Compiling rtk v0.5.0
|
|
error[E0308]: mismatched types
|
|
--> src/main.rs:10:5
|
|
|
|
|
10| "hello"
|
|
| ^^^^^^^ expected `i32`, found `&str`
|
|
|
|
error: aborting due to 1 previous error
|
|
"#;
|
|
let mut f = BlockStreamFilter::new(CargoBuildHandler::with_label("build"));
|
|
let result = run_block_filter(&mut f, input, 1);
|
|
assert!(result.contains("E0308"), "got: {}", result);
|
|
assert!(result.contains("mismatched types"), "got: {}", result);
|
|
assert!(result.contains("1 errors"), "got: {}", result);
|
|
assert!(!result.contains("aborting"), "got: {}", result);
|
|
}
|
|
|
|
#[test]
|
|
fn test_filter_cargo_build_json_warning_rendered() {
|
|
let input = concat!(
|
|
" Compiling v_cargo v0.1.0 (/tmp/v_cargo)\n",
|
|
r#"{"reason":"compiler-message","package_id":"v_cargo 0.1.0","message":{"level":"warning","message":"unused variable: `x`","rendered":"warning: unused variable: `x`\n --> src/main.rs:2:9"}}"#,
|
|
"\n",
|
|
r#"{"reason":"build-finished","success":true}"#,
|
|
"\n",
|
|
);
|
|
let result = filter_cargo_build_labeled(input, "build", 0);
|
|
assert!(result.contains("unused variable"), "got: {}", result);
|
|
assert!(result.contains("1 warnings"), "got: {}", result);
|
|
assert!(!result.contains("crates compiled"), "got: {}", result);
|
|
}
|
|
|
|
#[test]
|
|
fn test_cargo_check_stream_success_label() {
|
|
let input = " Checking demo v0.1.0 (/tmp/demo)\n Finished dev [unoptimized + debuginfo] target(s) in 0.42s\n";
|
|
let mut f = BlockStreamFilter::new(CargoBuildHandler::with_label("check"));
|
|
let result = run_block_filter(&mut f, input, 0);
|
|
assert!(result.contains("cargo check"), "got: {}", result);
|
|
assert!(!result.contains("cargo build"), "got: {}", result);
|
|
}
|
|
|
|
#[test]
|
|
fn test_filter_cargo_build_labeled_clippy_json_not_clean() {
|
|
let input = concat!(
|
|
" Checking demo v0.1.0 (/tmp/demo)\n",
|
|
r#"{"reason":"compiler-message","message":{"code":{"code":"E0308"},"level":"error","message":"mismatched types","rendered":"error[E0308]: mismatched types\n --> src/main.rs:2:18"}}"#,
|
|
"\n",
|
|
r#"{"reason":"build-finished","success":false}"#,
|
|
"\n",
|
|
);
|
|
let result = filter_cargo_build_labeled(input, "clippy", 1);
|
|
assert!(result.contains("cargo clippy:"), "got: {}", result);
|
|
assert!(result.contains("1 errors"), "got: {}", result);
|
|
assert!(
|
|
!result.contains("No issues found"),
|
|
"json clippy errors must not be swallowed: {}",
|
|
result
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_filter_cargo_clippy_json_clean() {
|
|
let input = concat!(
|
|
" Checking demo v0.1.0 (/tmp/demo)\n",
|
|
r#"{"reason":"compiler-artifact","package_id":"demo 0.1.0","target":{"name":"demo"}}"#,
|
|
"\n",
|
|
r#"{"reason":"build-finished","success":true}"#,
|
|
"\n",
|
|
);
|
|
assert_eq!(
|
|
filter_cargo_clippy_json(input, 0),
|
|
"cargo clippy: No issues found"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_filter_cargo_clippy_json_warnings() {
|
|
let input = concat!(
|
|
" Checking demo v0.1.0 (/tmp/demo)\n",
|
|
r#"{"reason":"compiler-message","message":{"level":"warning","message":"unused variable: `x`","rendered":"warning: unused variable: `x`\n --> src/main.rs:2:9"}}"#,
|
|
"\n",
|
|
r#"{"reason":"build-finished","success":true}"#,
|
|
"\n",
|
|
);
|
|
let result = filter_cargo_clippy_json(input, 0);
|
|
assert!(result.contains("unused variable"), "got: {}", result);
|
|
assert!(result.contains("cargo clippy: 0 errors, 1 warnings"), "got: {}", result);
|
|
}
|
|
|
|
#[test]
|
|
fn test_batch_filter_nonzero_exit_without_diagnostics_is_not_compiled() {
|
|
// build-script failure: exit 101, no compiler-message diagnostics parsed.
|
|
let input = concat!(
|
|
" Compiling demo v0.1.0 (/tmp/demo)\n",
|
|
r#"{"reason":"build-finished","success":false}"#,
|
|
"\n",
|
|
);
|
|
let result = filter_cargo_build_labeled(input, "build", 101);
|
|
assert!(
|
|
!result.contains("crates compiled"),
|
|
"a failed build must not be reported as compiled: {}",
|
|
result
|
|
);
|
|
assert!(result.contains("cargo build: failed (exit 101)"), "got: {}", result);
|
|
}
|
|
|
|
#[test]
|
|
fn test_failure_summary_line_is_first() {
|
|
let json = JsonDiagnostics {
|
|
errors: vec!["error[E0308]: mismatched types".to_string()],
|
|
warnings: Vec::new(),
|
|
};
|
|
let out = cargo_build_failure_summary(1, 1, 0, &json, "build", 101);
|
|
assert!(
|
|
out.starts_with("cargo build: 1 errors"),
|
|
"summary line must come first: {}",
|
|
out
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_filter_cargo_build_labeled_check() {
|
|
let input = concat!(
|
|
r#"{"reason":"compiler-message","message":{"level":"error","message":"mismatched types","rendered":"error[E0308]: mismatched types\n --> src/main.rs:2:18"}}"#,
|
|
"\n",
|
|
r#"{"reason":"build-finished","success":false}"#,
|
|
"\n",
|
|
);
|
|
let result = filter_cargo_build_labeled(input, "check", 1);
|
|
assert!(result.contains("cargo check:"), "got: {}", result);
|
|
assert!(!result.contains("cargo build:"), "got: {}", result);
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_json_diagnostics_strips_ansi() {
|
|
let output = concat!(
|
|
r#"{"reason":"compiler-message","message":{"level":"error","message":"mismatched types","rendered":"\u001b[1m\u001b[31merror[E0308]\u001b[0m: mismatched types"}}"#,
|
|
"\n",
|
|
r#"{"reason":"build-finished","success":false}"#,
|
|
"\n",
|
|
);
|
|
let json = extract_json_diagnostics(output);
|
|
assert_eq!(json.errors.len(), 1);
|
|
assert!(
|
|
!json.errors[0].contains('\u{1b}'),
|
|
"ansi escapes must be stripped: {:?}",
|
|
json.errors[0]
|
|
);
|
|
assert!(
|
|
json.errors[0].contains("error[E0308]"),
|
|
"got: {:?}",
|
|
json.errors[0]
|
|
);
|
|
}
|
|
|
|
|
|
#[test]
|
|
fn test_extract_json_diagnostics_skips_generated_summary() {
|
|
let output = concat!(
|
|
r#"{"reason":"compiler-message","message":{"level":"warning","message":"unused variable: `x`","rendered":"warning: unused variable: `x`"}}"#,
|
|
"\n",
|
|
r#"{"reason":"compiler-message","message":{"level":"warning","message":"`demo` (bin \"demo\") generated 1 warning","rendered":"warning: `demo` (bin \"demo\") generated 1 warning"}}"#,
|
|
"\n",
|
|
r#"{"reason":"build-finished","success":true}"#,
|
|
"\n",
|
|
);
|
|
let json = extract_json_diagnostics(output);
|
|
assert_eq!(json.warnings.len(), 1, "generated summary must not inflate the count");
|
|
assert!(
|
|
!json.warnings.iter().any(|w| w.contains("generated")),
|
|
"the generated summary line must not appear in the output"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_json_diagnostics_counts_ice_as_error() {
|
|
let output = concat!(
|
|
r#"{"reason":"compiler-message","message":{"level":"error: internal compiler error","message":"unexpected panic","rendered":"error: internal compiler error: unexpected panic"}}"#,
|
|
"\n",
|
|
r#"{"reason":"build-finished","success":false}"#,
|
|
"\n",
|
|
);
|
|
let json = extract_json_diagnostics(output);
|
|
assert_eq!(json.errors.len(), 1, "an ICE must count as an error");
|
|
assert!(json.errors[0].contains("internal compiler error"), "got: {:?}", json.errors[0]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_json_format_inline_eq() {
|
|
assert!(has_json_message_format(&["--message-format=json".into()]));
|
|
}
|
|
|
|
#[test]
|
|
fn test_json_format_space_separated() {
|
|
assert!(has_json_message_format(&[
|
|
"--message-format".into(),
|
|
"json".into(),
|
|
]));
|
|
}
|
|
|
|
#[test]
|
|
fn test_json_format_rendered_ansi() {
|
|
assert!(has_json_message_format(&[
|
|
"--message-format=json-diagnostic-rendered-ansi".into()
|
|
]));
|
|
}
|
|
|
|
#[test]
|
|
fn test_json_format_render_diagnostics() {
|
|
assert!(has_json_message_format(&[
|
|
"--message-format=json-render-diagnostics".into()
|
|
]));
|
|
}
|
|
|
|
#[test]
|
|
fn test_json_format_space_rendered() {
|
|
assert!(has_json_message_format(&[
|
|
"--message-format".into(),
|
|
"json-diagnostic-rendered-ansi".into(),
|
|
]));
|
|
}
|
|
|
|
#[test]
|
|
fn test_non_json_format_not_detected() {
|
|
assert!(!has_json_message_format(&["--message-format=human".into()]));
|
|
}
|
|
|
|
#[test]
|
|
fn test_no_format_flag() {
|
|
assert!(!has_json_message_format(&[
|
|
"--release".into(),
|
|
"-p".into(),
|
|
"my-crate".into(),
|
|
]));
|
|
}
|
|
|
|
#[test]
|
|
fn test_json_format_among_other_args() {
|
|
assert!(has_json_message_format(&[
|
|
"--release".into(),
|
|
"-p".into(),
|
|
"my-crate".into(),
|
|
"--message-format=json".into(),
|
|
]));
|
|
}
|
|
|
|
#[test]
|
|
fn test_json_format_trailing_message_format_no_value() {
|
|
assert!(!has_json_message_format(&["--message-format".into()]));
|
|
}
|
|
|
|
#[test]
|
|
fn test_json_format_last_occurrence_wins() {
|
|
assert!(!has_json_message_format(&[
|
|
"--message-format=json".into(),
|
|
"--message-format=human".into(),
|
|
]));
|
|
assert!(has_json_message_format(&[
|
|
"--message-format=human".into(),
|
|
"--message-format=json".into(),
|
|
]));
|
|
}
|
|
|
|
#[test]
|
|
fn test_filter_cargo_build_json_failure_not_compiled() {
|
|
let output = concat!(
|
|
r#"{"reason":"compiler-message","message":{"rendered":"error[E0277]: the trait bound is not satisfied","level":"error","message":"trait bound"}}"#,
|
|
"\n",
|
|
r#"{"reason":"build-finished","success":false}"#,
|
|
"\n",
|
|
);
|
|
let result = filter_cargo_build(output);
|
|
assert!(result.contains("E0277"), "got: {}", result);
|
|
assert!(result.contains("1 errors"), "got: {}", result);
|
|
assert!(!result.contains("crates compiled"), "got: {}", result);
|
|
}
|
|
|
|
#[test]
|
|
fn test_filter_cargo_build_json_errors_capped() {
|
|
let total = CAP_ERRORS + 5;
|
|
let mut output = String::new();
|
|
for i in 0..total {
|
|
output.push_str(&format!(
|
|
r#"{{"reason":"compiler-message","message":{{"code":{{"code":"E0308"}},"level":"error","message":"mismatched types","rendered":"error[E0308]: mismatched types ({})"}}}}"#,
|
|
i
|
|
));
|
|
output.push('\n');
|
|
}
|
|
output.push_str(r#"{"reason":"build-finished","success":false}"#);
|
|
output.push('\n');
|
|
|
|
let result = filter_cargo_build(&output);
|
|
let rendered = result.matches("error[E0308]").count();
|
|
assert_eq!(rendered, CAP_ERRORS, "json errors must be capped: {}", result);
|
|
assert!(
|
|
result.contains(&format!("… +{} more errors", total - CAP_ERRORS)),
|
|
"expected overflow hint: {}",
|
|
result
|
|
);
|
|
assert!(
|
|
result.contains(&format!("{} errors", total)),
|
|
"summary must report the real total: {}",
|
|
result
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_filter_cargo_build_json_savings() {
|
|
// Real --message-format=json lines carry a verbose envelope (spans,
|
|
// children, code, message) around the human `rendered` text. The filter
|
|
// keeps only `rendered` and caps the list, so savings come from both
|
|
// dropping the envelope and capping a large failing build.
|
|
let template = r#"{"reason":"compiler-message","package_id":"demo 0.1.0 (path+file:///tmp/demo)","manifest_path":"/tmp/demo/Cargo.toml","target":{"kind":["bin"],"crate_types":["bin"],"name":"demo","src_path":"/tmp/demo/src/main.rs","edition":"2021","doc":true,"doctest":false,"test":true},"message":{"rendered":"error[E0308]: mismatched types\n --> src/main.rs:IDX:18\n |\nIDX | let _xIDX: i32 = \"errIDX\";\n | --- ^^^^^^^ expected `i32`, found `&str`\n | |\n | expected due to this\n\n","$message_type":"diagnostic","children":[{"children":[],"code":null,"level":"note","message":"expected due to the type annotation here","rendered":null,"spans":[]}],"code":{"code":"E0308","explanation":null},"level":"error","message":"mismatched types","spans":[{"byte_end":40,"byte_start":33,"column_end":25,"column_start":18,"expansion":null,"file_name":"src/main.rs","is_primary":true,"label":"expected `i32`, found `&str`","line_end":IDX,"line_start":IDX,"suggested_replacement":null,"suggestion_applicability":null,"text":[{"highlight_end":25,"highlight_start":18,"text":" let _xIDX: i32 = \"errIDX\";"}]}]}}"#;
|
|
|
|
let total = CAP_ERRORS + 30;
|
|
let mut input = String::new();
|
|
for i in 0..total {
|
|
input.push_str(&template.replace("IDX", &i.to_string()));
|
|
input.push('\n');
|
|
}
|
|
input.push_str(r#"{"reason":"build-finished","success":false}"#);
|
|
input.push('\n');
|
|
|
|
let result = filter_cargo_build(&input);
|
|
|
|
// The cap is what bounds the output, so assert it holds here too.
|
|
let rendered = result.matches("error[E0308]").count();
|
|
assert_eq!(rendered, CAP_ERRORS, "json errors must be capped: {}", result);
|
|
assert!(
|
|
result.contains(&format!("… +{} more errors", total - CAP_ERRORS)),
|
|
"expected overflow hint: {}",
|
|
result
|
|
);
|
|
|
|
let raw = input.split_whitespace().count();
|
|
let out = result.split_whitespace().count();
|
|
let savings = 100.0 - (out as f64 / raw as f64) * 100.0;
|
|
assert!(
|
|
savings >= 60.0,
|
|
"token savings dropped below 60%: {savings:.1}%"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_cargo_test_stream_all_pass() {
|
|
let input = r#" Compiling rtk v0.5.0
|
|
Finished test [unoptimized + debuginfo] target(s) in 2.53s
|
|
Running target/debug/deps/rtk-abc123
|
|
|
|
running 15 tests
|
|
test utils::tests::test_truncate_short_string ... ok
|
|
test utils::tests::test_truncate_long_string ... ok
|
|
test utils::tests::test_strip_ansi_simple ... ok
|
|
|
|
test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
|
|
"#;
|
|
let mut f = BlockStreamFilter::new(CargoTestHandler::new());
|
|
let result = run_block_filter(&mut f, input, 0);
|
|
assert!(
|
|
result.contains("cargo test: 15 passed (1 suite, 0.01s)"),
|
|
"got: {}",
|
|
result
|
|
);
|
|
assert!(!result.contains("Compiling"), "got: {}", result);
|
|
}
|
|
|
|
#[test]
|
|
fn test_cargo_test_stream_failures() {
|
|
let input = r#"running 5 tests
|
|
test foo::test_a ... ok
|
|
test foo::test_b ... FAILED
|
|
test foo::test_c ... ok
|
|
|
|
failures:
|
|
|
|
---- foo::test_b stdout ----
|
|
thread 'foo::test_b' panicked at 'assert_eq!(1, 2)'
|
|
|
|
failures:
|
|
foo::test_b
|
|
|
|
test result: FAILED. 4 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out
|
|
"#;
|
|
let mut f = BlockStreamFilter::new(CargoTestHandler::new());
|
|
let result = run_block_filter(&mut f, input, 1);
|
|
assert!(result.contains("test_b"), "got: {}", result);
|
|
assert!(result.contains("panicked"), "got: {}", result);
|
|
}
|
|
|
|
#[test]
|
|
fn test_cargo_test_stream_multi_suite() {
|
|
let input = r#" Running unittests src/lib.rs (target/debug/deps/rtk-abc123)
|
|
|
|
running 50 tests
|
|
test result: ok. 50 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.45s
|
|
|
|
Running unittests src/main.rs (target/debug/deps/rtk-def456)
|
|
|
|
running 30 tests
|
|
test result: ok. 30 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.30s
|
|
"#;
|
|
let mut f = BlockStreamFilter::new(CargoTestHandler::new());
|
|
let result = run_block_filter(&mut f, input, 0);
|
|
assert!(
|
|
result.contains("cargo test: 80 passed (2 suites, 0.75s)"),
|
|
"got: {}",
|
|
result
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_cargo_test_stream_compile_error() {
|
|
let input = r#" Compiling rtk v0.31.0 (/workspace/projects/rtk)
|
|
error[E0425]: cannot find value `missing_symbol` in this scope
|
|
--> tests/repro_compile_fail.rs:3:13
|
|
|
|
|
3 | let _ = missing_symbol;
|
|
| ^^^^^^^^^^^^^^ not found in this scope
|
|
|
|
For more information about this error, try `rustc --explain E0425`.
|
|
error: could not compile `rtk` (test "repro_compile_fail") due to 1 previous error
|
|
"#;
|
|
let mut f = BlockStreamFilter::new(CargoTestHandler::new());
|
|
let result = run_block_filter(&mut f, input, 1);
|
|
assert!(result.contains("cargo test:"), "got: {}", result);
|
|
assert!(result.contains("1 errors"), "got: {}", result);
|
|
}
|
|
|
|
#[test]
|
|
fn test_cargo_test_stream_json_compile_error() {
|
|
let input = concat!(
|
|
" Compiling v_cargo v0.1.0 (/tmp/v_cargo)\n",
|
|
r#"{"reason":"compiler-message","package_id":"v_cargo 0.1.0","message":{"code":{"code":"E0425"},"level":"error","message":"cannot find value `missing_symbol` in this scope","rendered":"error[E0425]: cannot find value `missing_symbol` in this scope\n --> tests/repro.rs:3:13"}}"#,
|
|
"\n",
|
|
r#"{"reason":"build-finished","success":false}"#,
|
|
"\n",
|
|
);
|
|
let mut f = BlockStreamFilter::new(CargoTestHandler::new());
|
|
let result = run_block_filter(&mut f, input, 101);
|
|
assert!(!result.trim().is_empty(), "json compile error must not be silent");
|
|
assert!(result.contains("cargo test:"), "got: {}", result);
|
|
assert!(result.contains("1 errors"), "got: {}", result);
|
|
assert!(result.contains("E0425"), "diagnostic must be surfaced: {}", result);
|
|
}
|
|
|
|
#[test]
|
|
fn test_cargo_test_stream_no_diagnostic_falls_back_to_raw() {
|
|
// "could not compile" sets has_compile_errors but is skipped by the build
|
|
// re-scan, so the reused filter yields a success-shaped line. We must not
|
|
// trust it — fall back to the raw tail instead of a bogus "compiled".
|
|
let input = concat!(
|
|
" Compiling foo v0.1.0 (/tmp/foo)\n",
|
|
"error: could not compile `foo` (test \"bar\") due to previous error\n",
|
|
);
|
|
let mut f = BlockStreamFilter::new(CargoTestHandler::new());
|
|
let result = run_block_filter(&mut f, input, 101);
|
|
assert!(
|
|
!result.contains("crates compiled"),
|
|
"must not report a failed test run as compiled: {}",
|
|
result
|
|
);
|
|
assert!(result.contains("could not compile"), "got: {}", result);
|
|
}
|
|
}
|