Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
6b30fdd
fix(filters): remove max_lines cap from helm filter that truncates te…
KuaaMU Apr 30, 2026
6c4950e
feat(mvn)!: Rust module replacing TOML filter, adds test phase support
vdufloth May 17, 2026
cc152cd
fix(mvn): match Surefire 3.x close lines, preserve failure stack trail
vdufloth May 21, 2026
77e28d0
refactor(mvn): drop production duration normalisation
vdufloth May 21, 2026
97dbf98
feat(mvn): filter mvn -q quiet-mode output
vdufloth May 21, 2026
97bd2a7
fix(mvn): preserve compile-error continuation in filter_surefire
vdufloth May 25, 2026
5459c6d
refactor(mvn): extract shared SurefireBlock state machine
vdufloth May 25, 2026
4609102
fix(mvn): keep multi-module Reactor Summary rows
vdufloth May 25, 2026
be6c812
feat(mvn): cap failing-class blocks and Failures summary entries
vdufloth May 25, 2026
92a9218
refactor(mvn): polish — strip_prefix, borrow buffers, CRLF tests
vdufloth May 25, 2026
f58333c
chore(mvn): drop unrelated Cargo.lock churn from PR
vdufloth May 25, 2026
ad2bfd3
fix(aws): preserve values in JSON output for unsupported subcommands
dessite May 28, 2026
a2a63e1
fix(curl): passthrough binary downloads to prevent UTF-8 corruption
pszymkowiak May 31, 2026
16d6599
refacto(cmds): strip decorator noise from filter output
aeppling Jun 5, 2026
0a630fe
Merge pull request #2289 from rtk-ai/refacto/strip-output-decorators
aeppling Jun 5, 2026
1050cfe
fix(mvn): re-arm failure trail on per-test sublines
vdufloth Jun 6, 2026
be28a51
fix(ci): pin fixture line endings, harden CRLF tests
vdufloth Jun 6, 2026
df76528
fix(mvn): strip post-failure help boilerplate in non-quiet mode
vdufloth Jun 6, 2026
35273c2
Merge pull request #2181 from rtk-ai/fix/curl-binary-passthrough-1087
aeppling Jun 7, 2026
9574007
Merge pull request #2135 from dessite/fix/aws-json-preserves-values-f…
aeppling Jun 7, 2026
63a76de
Merge pull request #1645 from KuaaMU/master
aeppling Jun 7, 2026
f026cfd
Merge branch 'develop' into feat/mvn-rust-module
vdufloth Jun 7, 2026
3c0ee94
refactor(mvn): bind failure cap to truncate::CAP_WARNINGS, drop confi…
vdufloth Jun 7, 2026
f8bc856
style(mvn): align overflow tail with canonical '… +N more {label}' shape
vdufloth Jun 7, 2026
047f454
Merge pull request #1956 from vdufloth/feat/mvn-rust-module
aeppling Jun 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Fixtures are byte-exact filter inputs; eol conversion must never touch them.
# Without this, Windows runners (core.autocrlf=true) check fixtures out with
# CRLF, and tests that synthesize CRLF input from them produce \r\r\n.
tests/fixtures/** -text
35 changes: 30 additions & 5 deletions src/cmds/cloud/aws_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ fn is_structured_operation(args: &[String]) -> bool {
|| op == "receive-message"
}

/// Generic strategy: force --output json for structured ops, compress via json_cmd schema
/// Generic strategy: force --output json for structured ops, compress via json_cmd compact (values preserved)
fn run_generic(subcommand: &str, args: &[String], verbose: u8, full_sub: &str) -> Result<i32> {
let timer = tracking::TimedExecution::start();

Expand Down Expand Up @@ -256,10 +256,10 @@ fn run_generic(subcommand: &str, args: &[String], verbose: u8, full_sub: &str) -
return Ok(crate::core::utils::exit_code_from_output(&output, "aws"));
}

let filtered = match json_cmd::filter_json_string(&raw, JSON_COMPRESS_DEPTH) {
Ok(schema) => {
println!("{}", schema);
schema
let filtered = match json_cmd::filter_json_compact(&raw, JSON_COMPRESS_DEPTH) {
Ok(compact) => {
println!("{}", compact);
compact
}
Err(_) => {
// Fallback: print raw (maybe not JSON)
Expand Down Expand Up @@ -2749,4 +2749,29 @@ upload: file10.txt to s3://bucket/file10.txt
// Should report all 30 failures, not capped at MAX_ITEMS (20)
assert!(result.text.contains("30 failed"));
}

// Regression: generic AWS path (unsupported subcommand returning JSON) must
// compress responses while preserving values, not collapse them to schema
// type names. Calls the primitive used at aws_cmd.rs run_generic line 259.
#[test]
fn test_aws_unsupported_subcommand_json_preserves_values() {
let fixture = include_str!(
"../../../tests/fixtures/aws_backup_describe_global_settings.json"
);
let output = json_cmd::filter_json_compact(fixture, JSON_COMPRESS_DEPTH)
.expect("filter_json_compact must not error on valid AWS JSON");

assert!(
output.contains("\"false\""),
"values must be preserved (expected literal \"false\"), got:\n{output}"
);
assert!(
!output.contains(": string"),
"schema-type leakage detected (\": string\" found), got:\n{output}"
);
assert!(
output.contains("isMpaEnabled"),
"object keys must be preserved, got:\n{output}"
);
}
}
91 changes: 81 additions & 10 deletions src/cmds/cloud/curl_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,18 @@
//! The condensed-form-with-tee-hint path is reserved for non-JSON bodies on
//! a real terminal where a human reads the output and the tee file gives the
//! LLM a way to recover the raw response.
//!
//! Binary downloads (any non-UTF-8 byte sequence) are written through to
//! stdout as raw bytes, bypassing the UTF-8 lossy conversion that would
//! otherwise replace non-UTF-8 bytes with U+FFFD and corrupt the stream
//! (`#1087`).

use crate::core::tee::force_tee_hint;
use crate::core::tracking;
use crate::core::{stream::exec_capture, utils::resolved_command};
use crate::core::utils::resolved_command;
use anyhow::{Context, Result};
use std::borrow::Cow;
use std::io::IsTerminal;
use std::io::{IsTerminal, Write};

const MAX_RESPONSE_SIZE: usize = 500;

Expand All @@ -28,22 +33,46 @@ pub fn run(args: &[String], verbose: u8) -> Result<i32> {
eprintln!("Running: curl -s {}", args.join(" "));
}

let result = exec_capture(&mut cmd).context("Failed to run curl")?;
// Capture stdout as raw bytes (not UTF-8 String) so binary downloads
// survive intact. `String::from_utf8_lossy` would otherwise replace
// every non-UTF-8 byte with U+FFFD (3 bytes), corrupting e.g. gzip
// magic `1f 8b 08 00` into `1f ef bf bd 08 00` (#1087).
let output = cmd.output().context("Failed to run curl")?;
let exit_code = output.status.code().unwrap_or(1);

// Skip filtering on failure: curl can return HTML error bodies that would
// be misleading to summarize, and we want the real exit code surfaced.
if !result.success() {
let msg = if result.stderr.trim().is_empty() {
result.stdout.trim().to_string()
if !output.status.success() {
let stderr_str = String::from_utf8_lossy(&output.stderr);
let stdout_str = String::from_utf8_lossy(&output.stdout);
let msg = if stderr_str.trim().is_empty() {
stdout_str.trim().to_string()
} else {
result.stderr.trim().to_string()
stderr_str.trim().to_string()
};
eprintln!("FAILED: curl {}", msg);
return Ok(result.exit_code);
return Ok(exit_code);
}

let exit_code = result.exit_code;
let raw = result.stdout;
// Binary detection: if the body is not valid UTF-8, `from_utf8_lossy`
// would replace every invalid byte with U+FFFD and corrupt the stream
// (gzip, zip, png, pdf, elf, ... — any binary format). Write raw bytes
// through and skip filtering. Tracking is recorded as passthrough
// (0% savings) since token counts over binary content have no meaning.
if is_binary(&output.stdout) {
let stdout = std::io::stdout();
let mut handle = stdout.lock();
handle
.write_all(&output.stdout)
.context("Failed to write binary response to stdout")?;
timer.track_passthrough(
&format!("curl {}", args.join(" ")),
&format!("rtk curl {}", args.join(" ")),
);
return Ok(exit_code);
}

let raw = String::from_utf8_lossy(&output.stdout).into_owned();
let is_tty = std::io::stdout().is_terminal();
let filtered = filter_curl_output(&raw, is_tty);

Expand All @@ -62,6 +91,17 @@ pub fn run(args: &[String], verbose: u8) -> Result<i32> {
Ok(exit_code)
}

/// Returns `true` if `bytes` is not valid UTF-8 — which is exactly the
/// condition under which `from_utf8_lossy` would replace invalid bytes
/// with U+FFFD and corrupt downstream consumers (`#1087`).
///
/// This is correct by construction: the only reason to passthrough raw
/// bytes is to avoid the lossy conversion, and the only bytes that suffer
/// from it are the non-UTF-8 ones.
fn is_binary(bytes: &[u8]) -> bool {
std::str::from_utf8(bytes).is_err()
}

fn filter_curl_output(raw: &str, is_tty: bool) -> FilterResult<'_> {
let trimmed = raw.trim();

Expand Down Expand Up @@ -239,4 +279,35 @@ mod tests {
let json_result = filter_curl_output(&json_payload, true);
assert!(matches!(json_result.content, Cow::Borrowed(_)));
}

// --- is_binary tests ----------------------------------------------------

#[test]
fn test_is_binary_gzip_magic_is_not_utf8() {
// gzip magic 1f 8b — 0x8b is an invalid UTF-8 continuation byte
let bytes = [0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03];
assert!(is_binary(&bytes));
}

#[test]
fn test_is_binary_valid_utf8_text_is_not_binary() {
assert!(!is_binary(br#"{"key": "value"}"#));
assert!(!is_binary(b"<!DOCTYPE html>\n<html><body>Hi</body></html>"));
assert!(!is_binary(b"Plain ASCII text"));
assert!(!is_binary("Héllo wörld — emojis 🚀 ✓".as_bytes()));
}

#[test]
fn test_is_binary_empty_is_not_binary() {
// Empty input is technically valid UTF-8 and trivially safe to filter.
assert!(!is_binary(&[]));
}

#[test]
fn test_is_binary_text_with_nul_is_not_binary() {
// NUL is valid UTF-8 (U+0000). Unusual in HTTP responses but the
// function honors UTF-8 strictly — caller can still filter such
// content safely. The bug we're fixing is only invalid UTF-8 bytes.
assert!(!is_binary(b"text with\0embedded nul"));
}
}
2 changes: 1 addition & 1 deletion src/cmds/cloud/wget_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ pub fn run_stdout(url: &str, args: &[String], verbose: u8) -> Result<i32> {
total,
format_size(result.stdout.len() as u64)
));
rtk_output.push_str("--- first 10 lines ---\n");
rtk_output.push_str("first 10 lines:\n");
for line in lines.iter().take(10) {
rtk_output.push_str(&format!("{}\n", truncate_line(line, 100)));
}
Expand Down
34 changes: 3 additions & 31 deletions src/cmds/dotnet/dotnet_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -368,7 +368,6 @@ fn format_dotnet_format_output(
}

let mut output = format!("Format: {} files need formatting", changed_count);
output.push_str("\n---------------------------------------");

const MAX_FORMAT_FILES: usize = CAP_LIST;
for (index, file) in summary
Expand Down Expand Up @@ -1056,12 +1055,6 @@ fn format_build_output(summary: &binlog::BuildSummary, _binlog_path: &Path) -> S
}
}

let sep = if !warnings.is_empty() || !errors.is_empty() {
"---------------------------------------"
} else {
""
};

let verdict = format!(
"{} dotnet build: {} projects, {} errors, {} warnings ({})",
status_icon,
Expand All @@ -1076,7 +1069,7 @@ fn format_build_output(summary: &binlog::BuildSummary, _binlog_path: &Path) -> S
// definitive verdict. Mirrors native `dotnet build`, which ends with
// `Build succeeded.` / `Build FAILED.`. See issue #1574.
// Warnings before errors: errors survive `| tail -N` immediately above the verdict.
[warnings, errors, sep.into(), verdict]
[warnings, errors, verdict]
.into_iter()
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
Expand Down Expand Up @@ -1218,24 +1211,9 @@ fn format_test_output(
}
}

let sep = if !failed_tests_section.is_empty()
|| !warnings_section.is_empty()
|| !errors_section.is_empty()
{
"---------------------------------------"
} else {
""
};

// Status line emitted last; see format_build_output (issue #1574).
// Warnings before errors: errors survive `| tail -N` immediately above the verdict.
[
failed_tests_section,
warnings_section,
errors_section,
sep.into(),
header,
]
[failed_tests_section, warnings_section, errors_section, header]
.into_iter()
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
Expand Down Expand Up @@ -1311,20 +1289,14 @@ fn format_restore_output(
}
}

let sep = if !warnings_section.is_empty() || !errors_section.is_empty() {
"---------------------------------------"
} else {
""
};

let verdict = format!(
"{} dotnet restore: {} projects, {} errors, {} warnings ({})",
status_icon, summary.restored_projects, summary.errors, summary.warnings, duration
);

// Status line emitted last; see format_build_output (issue #1574).
// Warnings before errors: errors survive `| tail -N` immediately above the verdict.
[warnings_section, errors_section, sep.into(), verdict]
[warnings_section, errors_section, verdict]
.into_iter()
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
Expand Down
6 changes: 3 additions & 3 deletions src/cmds/git/git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -193,10 +193,10 @@ fn run_diff(

let mut final_output = result.stdout.clone();
if !diff_result.stdout.is_empty() {
println!("\n--- Changes ---");
println!("\nChanges:");
let compacted = compact_diff(&diff_result.stdout, max_lines.unwrap_or(500));
println!("{}", compacted);
final_output.push_str("\n--- Changes ---\n");
final_output.push_str("\nChanges:\n");
final_output.push_str(&compacted);
}

Expand Down Expand Up @@ -305,7 +305,7 @@ fn run_show(
let mut final_output = summary_result.stdout.clone();
if !diff_text.is_empty() {
if verbose > 0 {
println!("\n--- Changes ---");
println!("\nChanges:");
}
let compacted = compact_diff(diff_text, max_lines.unwrap_or(500));
println!("{}", compacted);
Expand Down
3 changes: 0 additions & 3 deletions src/cmds/go/go_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -431,7 +431,6 @@ pub(crate) fn filter_go_test_json(output: &str) -> String {
result.push_str(&format!(", {} skipped", total_skip));
}
result.push_str(&format!(" in {} packages\n", total_packages));
result.push_str("═══════════════════════════════════════\n");

// Show package-level failures first (timeouts, signals, panics).
// Skip packages that already have individual test-level failures — those are displayed
Expand Down Expand Up @@ -594,7 +593,6 @@ fn filter_go_build_with_exit(output: &str, exit_code: i32) -> String {

let mut result = String::new();
result.push_str(&format!("Go build: {} errors\n", errors.len()));
result.push_str("═══════════════════════════════════════\n");

const MAX_GO_BUILD_ERRORS: usize = CAP_ERRORS;
for (i, error) in errors.iter().take(MAX_GO_BUILD_ERRORS).enumerate() {
Expand Down Expand Up @@ -718,7 +716,6 @@ fn filter_go_vet(output: &str) -> String {

let mut result = String::new();
result.push_str(&format!("Go vet: {} issues\n", issues.len()));
result.push_str("═══════════════════════════════════════\n");

const MAX_GO_VET_ISSUES: usize = CAP_ERRORS;
for (i, issue) in issues.iter().take(MAX_GO_VET_ISSUES).enumerate() {
Expand Down
1 change: 0 additions & 1 deletion src/cmds/go/golangci_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,6 @@ pub(crate) fn filter_golangci_json(output: &str, version: u32) -> String {
"golangci-lint: {} issues in {} files\n",
total_issues, total_files
));
result.push_str("═══════════════════════════════════════\n");

// Show top linters
let mut linter_counts: Vec<_> = by_linter.iter().collect();
Expand Down
3 changes: 0 additions & 3 deletions src/cmds/js/lint_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,6 @@ fn filter_eslint_json(output: &str) -> String {
"ESLint: {} errors, {} warnings in {} files\n",
total_errors, total_warnings, total_files
));
result.push_str("═══════════════════════════════════════\n");

// Show top rules
let mut rule_counts: Vec<_> = by_rule.iter().collect();
Expand Down Expand Up @@ -394,7 +393,6 @@ fn filter_pylint_json(output: &str) -> String {
result.push('\n');
}

result.push_str("═══════════════════════════════════════\n");

// Show top symbols (rules)
let mut symbol_counts: Vec<_> = by_symbol.iter().collect();
Expand Down Expand Up @@ -471,7 +469,6 @@ fn filter_generic_lint(output: &str) -> String {

let mut result = String::new();
result.push_str(&format!("Lint: {} errors, {} warnings\n", errors, warnings));
result.push_str("═══════════════════════════════════════\n");

const MAX_ISSUES: usize = CAP_ERRORS;
for issue in issues.iter().take(MAX_ISSUES) {
Expand Down
1 change: 0 additions & 1 deletion src/cmds/js/next_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,6 @@ fn filter_next_build(output: &str) -> String {
// Build filtered output
let mut result = String::new();
result.push_str("Next.js Build\n");
result.push_str("═══════════════════════════════════════\n");

if already_built && routes_total == 0 {
result.push_str("Already built (using cache)\n\n");
Expand Down
1 change: 0 additions & 1 deletion src/cmds/js/prettier_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,6 @@ pub fn filter_prettier_output(output: &str) -> String {
"Prettier: {} files need formatting\n",
files_to_format.len()
));
result.push_str("═══════════════════════════════════════\n");

const MAX_PRETTIER_FILES: usize = CAP_WARNINGS;
for (i, file) in files_to_format.iter().take(MAX_PRETTIER_FILES).enumerate() {
Expand Down
1 change: 0 additions & 1 deletion src/cmds/js/prisma_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,6 @@ fn filter_migrate_dev(output: &str) -> String {

if !migration_name.is_empty() {
result.push_str(&format!("Migration: {}\n", migration_name));
result.push_str("═══════════════════════════════════════\n");
}

result.push_str("Changes:\n");
Expand Down
3 changes: 1 addition & 2 deletions src/cmds/js/tsc_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ impl BlockHandler for TscHandler {
}

let mut result = format!(
"═══════════════════════════════════════\nTypeScript: {} errors in {} files\n",
"TypeScript: {} errors in {} files\n",
self.error_count,
self.files.len()
);
Expand Down Expand Up @@ -174,7 +174,6 @@ pub(crate) fn filter_tsc_output(output: &str) -> String {
errors.len(),
by_file.len()
));
result.push_str("═══════════════════════════════════════\n");

// Top error codes summary (compact, one line)
let mut code_counts: Vec<_> = by_code.iter().collect();
Expand Down
Loading
Loading