feat(config): added minimizer output fields and compact shell summaries

- Added minimizer API fields (`MinimizerResult.text`, `settingsHash`) and shell options for minimized output behavior.
- Changed bash execution to print minimized text output and append artifact footers only after minimized output is saved.
- Added UTF-8-safe shell streaming with `on_chunk` callback routing and `replace()` output sink updates.
- Added core filter enhancements for git, cargo, go, and listing/python outputs to produce compact summaries.
- Added RTK command filters and fixtures to strip noise, truncate output, and normalize success fallbacks.
This commit is contained in:
can1357
2026-04-24 11:00:55 +02:00
parent 57b5a7e8cb
commit c61a5b9a56
46 changed files with 2694 additions and 120 deletions
+92 -20
View File
@@ -130,13 +130,18 @@ pub struct ShellRunOptions<'env> {
///
/// Surfaced when the minimizer actually rewrote the command's output. The
/// session layer is expected to persist `original_text` via its
/// `ArtifactManager` and splice the resulting `artifact://<id>` reference
/// into whatever is shown to the agent.
/// `ArtifactManager`, splice the resulting `artifact://<id>` reference
/// into `text`, and replace any previously streamed raw output with the
/// minimized text.
#[napi(object)]
pub struct MinimizerResult {
/// Dispatch label produced by the minimizer (e.g. `"git"`,
/// `"pipeline:gradle"`, `"pipeline+builtin"`).
pub filter: String,
/// The minimized replacement text. Callers that streamed raw chunks
/// during execution should clear and replace their accumulated output
/// with this text.
pub text: String,
/// The full original capture, before minimization.
pub original_text: String,
/// Captured byte length before minimization.
@@ -669,18 +674,24 @@ async fn run_shell_command(
let reader_cancel = CancellationToken::new();
let (activity_tx, mut activity_rx) = mpsc::channel::<()>(1);
let (reader_callback, final_callback) = if should_minimize {
(None, on_chunk)
} else {
(on_chunk, None)
};
// Stream every raw chunk to the caller live, regardless of whether
// minimization is enabled. When minimization actually transforms the
// output, we propagate the replacement text via `MinimizerResult.text`
// so the caller can swap their accumulated buffer for the minimized
// version without losing intermediate progress updates.
let reader_callback = on_chunk;
let mut reader_handle = tokio::spawn({
let reader_cancel = reader_cancel.clone();
async move {
if should_minimize {
let output =
read_output_buffered(reader_file, reader_cancel, activity_tx, max_capture_bytes)
.await;
let output = read_output_buffered(
reader_file,
reader_callback,
reader_cancel,
activity_tx,
max_capture_bytes,
)
.await;
Result::<OutputRead>::Ok(OutputRead::Buffered(output))
} else {
Box::pin(read_output(reader_file, reader_callback, reader_cancel, activity_tx)).await;
@@ -770,12 +781,22 @@ async fn run_shell_command(
&& let Some(config) = options.minimizer.as_ref()
{
if output.exceeded {
let text = if let Some(marker_state) = marker_state.as_ref() {
minimizer::engine::strip_markers(&output.text, marker_state)
} else {
output.text
};
emit_chunk(&text, final_callback.as_ref());
// Exceeded captures still flow through the raw stream already. Only
// surface a replacement when we actually need to strip markers.
if let Some(marker_state) = marker_state.as_ref() {
let stripped = minimizer::engine::strip_markers(&output.text, marker_state);
if stripped != output.text {
let input_bytes = u32::try_from(output.text.len()).unwrap_or(u32::MAX);
let output_bytes = u32::try_from(stripped.len()).unwrap_or(u32::MAX);
minimized_out = Some(MinimizerResult {
filter: "marker-strip".to_string(),
text: stripped,
original_text: output.text,
input_bytes,
output_bytes,
});
}
}
} else {
let minimized = match (minimizer_mode, marker_state.as_ref()) {
(minimizer::engine::MinimizerMode::WholeCommand, _) => {
@@ -786,15 +807,16 @@ async fn run_shell_command(
},
_ => minimizer::MinimizerOutput::passthrough(&output.text),
};
emit_chunk(&minimized.text, final_callback.as_ref());
if minimized.changed
&& let Some(original) = minimized.original_text
{
let output_bytes = u32::try_from(minimized.text.len()).unwrap_or(u32::MAX);
minimized_out = Some(MinimizerResult {
filter: minimized.filter.to_string(),
filter: minimized.filter.to_string(),
text: minimized.text,
original_text: original,
input_bytes: u32::try_from(minimized.input_bytes).unwrap_or(u32::MAX),
output_bytes: u32::try_from(minimized.text.len()).unwrap_or(u32::MAX),
input_bytes: u32::try_from(minimized.input_bytes).unwrap_or(u32::MAX),
output_bytes,
});
}
}
@@ -1060,14 +1082,20 @@ async fn read_output(
async fn read_output_buffered(
reader: fs::File,
on_chunk: Option<ThreadsafeFunction<String>>,
cancel_token: CancellationToken,
activity: mpsc::Sender<()>,
max_capture_bytes: usize,
) -> BufferedOutput {
const REPLACEMENT: &str = "\u{FFFD}";
const BUF: usize = 65536;
let mut buf = vec![0u8; BUF];
let mut captured = Vec::new();
let mut exceeded = false;
// Pending bytes from a prior read that ended mid-UTF-8 sequence. We hold
// them back so we emit only valid UTF-8 to the streaming callback while
// still capturing every byte into `captured` for post-processing.
let mut pending = Vec::<u8>::new();
#[cfg(unix)]
let Ok(reader) = register_nonblocking_pipe(reader) else {
@@ -1116,6 +1144,50 @@ async fn read_output_buffered(
exceeded = true;
}
captured.extend_from_slice(&buf[..n]);
// Stream whatever is validly decodable *right now* to the callback,
// carrying incomplete trailing UTF-8 bytes over to the next iteration.
if let Some(cb) = on_chunk.as_ref() {
pending.extend_from_slice(&buf[..n]);
while !pending.is_empty() {
match str::from_utf8(&pending) {
Ok(text) => {
emit_chunk(text, Some(cb));
pending.clear();
break;
},
Err(err) => {
let p = err.valid_up_to();
if p > 0 {
// SAFETY: [..p] is valid UTF-8 per valid_up_to().
let text = unsafe { str::from_utf8_unchecked(&pending[..p]) };
emit_chunk(text, Some(cb));
pending.drain(..p);
}
match err.error_len() {
Some(skip) => {
emit_chunk(REPLACEMENT, Some(cb));
pending.drain(..skip);
},
None => break,
}
},
}
}
}
}
// Flush any trailing bytes the streaming decoder held back at EOF.
if let Some(cb) = on_chunk.as_ref() {
for chunk in pending.utf8_chunks() {
let valid = chunk.valid();
if !valid.is_empty() {
emit_chunk(valid, Some(cb));
}
if !chunk.invalid().is_empty() {
emit_chunk(REPLACEMENT, Some(cb));
}
}
}
BufferedOutput { text: String::from_utf8_lossy(&captured).into_owned(), exceeded }
@@ -0,0 +1,37 @@
# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/ansible-playbook.toml
# match_command adapted for the local (program, subcommand) dispatch model.
[filters.ansible-playbook]
description = "Compact ansible-playbook output"
match_command = "^ansible-playbook$"
strip_ansi = true
strip_lines_matching = [
"^\\s*$",
"^ok: \\[",
"^skipping: \\[",
]
max_lines = 60
[[tests.ansible-playbook]]
name = "strips ok and skipping lines, keeps changed and failures"
input = """
PLAY [all] *********************************************************************
TASK [Gathering Facts] *********************************************************
ok: [web01]
ok: [web02]
TASK [Install nginx] ***********************************************************
changed: [web01]
skipping: [web02]
PLAY RECAP *********************************************************************
web01 : ok=2 changed=1 unreachable=0 failed=0
web02 : ok=1 changed=0 unreachable=0 failed=0
"""
expected = "PLAY [all] *********************************************************************\nTASK [Gathering Facts] *********************************************************\nTASK [Install nginx] ***********************************************************\nchanged: [web01]\nPLAY RECAP *********************************************************************\nweb01 : ok=2 changed=1 unreachable=0 failed=0\nweb02 : ok=1 changed=0 unreachable=0 failed=0\n"
[[tests.ansible-playbook]]
name = "failed task preserved"
input = "TASK [Start service] ***\nfailed: [web01] => {\"msg\": \"Service not found\"}\nPLAY RECAP ***\nweb01 : ok=1 failed=1"
expected = "TASK [Start service] ***\nfailed: [web01] => {\"msg\": \"Service not found\"}\nPLAY RECAP ***\nweb01 : ok=1 failed=1\n"
@@ -0,0 +1,50 @@
# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/basedpyright.toml
# match_command adapted for the local (program, subcommand) dispatch model.
[filters.basedpyright]
description = "Compact basedpyright type checker output — strip blank lines, keep errors"
match_command = "^basedpyright$"
strip_ansi = true
strip_lines_matching = [
"^\\s*$",
"^Searching for source files",
"^Found \\d+ source file",
"^Pyright \\d+\\.\\d+",
"^basedpyright \\d+\\.\\d+",
]
max_lines = 50
on_empty = "basedpyright: ok"
[[tests.basedpyright]]
name = "strips noise, keeps errors and summary"
input = """
basedpyright 1.22.0
Searching for source files
Found 42 source files
/home/user/app/main.py
/home/user/app/main.py:10:5 - error: "foo" is not defined (reportUndefinedVariable)
/home/user/app/main.py:25:1 - error: Type "str" is not assignable to type "int" (reportAssignmentType)
/home/user/app/utils.py
/home/user/app/utils.py:8:9 - warning: Variable "x" is not accessed (reportUnusedVariable)
3 errors, 1 warning, 0 informations
"""
expected = "/home/user/app/main.py\n /home/user/app/main.py:10:5 - error: \"foo\" is not defined (reportUndefinedVariable)\n /home/user/app/main.py:25:1 - error: Type \"str\" is not assignable to type \"int\" (reportAssignmentType)\n/home/user/app/utils.py\n /home/user/app/utils.py:8:9 - warning: Variable \"x\" is not accessed (reportUnusedVariable)\n3 errors, 1 warning, 0 informations\n"
[[tests.basedpyright]]
name = "clean output"
input = """
basedpyright 1.22.0
Searching for source files
Found 10 source files
0 errors, 0 warnings, 0 informations
"""
expected = "0 errors, 0 warnings, 0 informations\n"
[[tests.basedpyright]]
name = "empty input returns on_empty message"
input = ""
expected = "basedpyright: ok"
@@ -0,0 +1,48 @@
# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/biome.toml
# match_command adapted for the local (program, subcommand) dispatch model.
[filters.biome]
description = "Compact Biome lint/format output — strip blank lines, keep diagnostics"
match_command = "^biome$"
strip_ansi = true
strip_lines_matching = [
"^\\s*$",
"^Checked \\d+ file",
"^Fixed \\d+ file",
"^The following command",
"^Run it with",
]
max_lines = 50
on_empty = "biome: ok"
[[tests.biome]]
name = "lint strips noise, keeps diagnostics"
input = """
Checked 42 files in 0.5s
src/app.tsx:5:3 lint/suspicious/noExplicitAny ━━━━━━━━━━━━━━━━━━━━
× Unexpected any. Specify a different type.
3 │ interface Props {
4 │ data: any;
5 │ ^^^
src/utils.ts:12:1 lint/complexity/noForEach ━━━━━━━━━━━━━━━━━━━━
× Prefer for...of instead of forEach.
12 │ items.forEach(item => process(item));
│ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Found 2 errors.
"""
expected = "src/app.tsx:5:3 lint/suspicious/noExplicitAny ━━━━━━━━━━━━━━━━━━━━\n × Unexpected any. Specify a different type.\n 3 │ interface Props {\n 4 │ data: any;\n 5 │ ^^^\nsrc/utils.ts:12:1 lint/complexity/noForEach ━━━━━━━━━━━━━━━━━━━━\n × Prefer for...of instead of forEach.\n 12 │ items.forEach(item => process(item));\n │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\nFound 2 errors.\n"
[[tests.biome]]
name = "clean check"
input = """
Checked 42 files in 0.3s
"""
expected = "biome: ok"
[[tests.biome]]
name = "empty input returns on_empty message"
input = ""
expected = "biome: ok"
@@ -0,0 +1,41 @@
# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/brew-install.toml
# match_command adapted for the local (program, subcommand) dispatch model.
[filters.brew-install]
description = "Compact brew install/upgrade output — strip downloads, short-circuit when already installed"
match_command = "^brew$"
match_subcommand = "^(install|upgrade)$"
strip_ansi = true
strip_lines_matching = [
"^\\s*$",
"^==> Downloading",
"^==> Pouring",
"^Already downloaded:",
"^###",
"^==> Fetching",
]
match_output = [
{ pattern = "already installed", message = "ok (already installed)" },
]
max_lines = 20
[[tests.brew-install]]
name = "already installed short-circuits"
input = """
Warning: rtk 0.27.1 is already installed and up-to-date.
To reinstall 0.27.1, run:
brew reinstall rtk
"""
expected = "ok (already installed)"
[[tests.brew-install]]
name = "install strips download lines"
input = """
==> Fetching jq
==> Downloading https://homebrew.bintray.com/bottles/jq-1.7.1.arm64_sonoma.bottle.tar.gz
######################################################################## 100.0%
==> Pouring jq-1.7.1.arm64_sonoma.bottle.tar.gz
==> Summary
/opt/homebrew/Cellar/jq/1.7.1: 18 files, 1.2MB
"""
expected = "==> Summary\n/opt/homebrew/Cellar/jq/1.7.1: 18 files, 1.2MB\n"
@@ -0,0 +1,65 @@
# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/bundle-install.toml
# match_command adapted for the local (program, subcommand) dispatch model.
[filters.bundle-install]
description = "Compact bundle install/update — strip 'Using' lines, keep installs and errors"
match_command = "^bundle$"
match_subcommand = "^(install|update)$"
strip_ansi = true
strip_lines_matching = [
"^Using ",
"^\\s*$",
"^Fetching gem metadata",
"^Resolving dependencies",
]
match_output = [
{ pattern = "Bundle complete!", message = "ok bundle: complete" },
{ pattern = "Bundle updated!", message = "ok bundle: updated" },
]
max_lines = 30
[[tests.bundle-install]]
name = "all cached short-circuits"
input = """
Using bundler 2.5.6
Using rake 13.1.0
Using ast 2.4.2
Using base64 0.2.0
Using minitest 5.22.2
Bundle complete! 85 Gemfile dependencies, 200 gems now installed.
Use `bundle info [gemname]` to see where a bundled gem is installed.
"""
expected = "ok bundle: complete"
[[tests.bundle-install]]
name = "mixed install keeps Fetching and Installing lines"
input = """
Fetching gem metadata from https://rubygems.org/.........
Resolving dependencies...
Using rake 13.1.0
Using ast 2.4.2
Fetching rspec 3.13.0
Installing rspec 3.13.0
Using rubocop 1.62.0
Fetching simplecov 0.22.0
Installing simplecov 0.22.0
Bundle complete! 85 Gemfile dependencies, 202 gems now installed.
"""
expected = "ok bundle: complete"
[[tests.bundle-install]]
name = "update output"
input = """
Fetching gem metadata from https://rubygems.org/.........
Resolving dependencies...
Using rake 13.1.0
Fetching rspec 3.14.0 (was 3.13.0)
Installing rspec 3.14.0 (was 3.13.0)
Bundle updated!
"""
expected = "ok bundle: updated"
[[tests.bundle-install]]
name = "empty output"
input = ""
expected = ""
@@ -0,0 +1,44 @@
# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/composer-install.toml
# match_command adapted for the local (program, subcommand) dispatch model.
[filters.composer-install]
description = "Compact composer install/update/require output — strip downloads, short-circuit when up-to-date"
match_command = "^composer$"
match_subcommand = "^(install|update|require)$"
strip_ansi = true
strip_lines_matching = [
"^\\s*$",
"^ - Downloading ",
"^ - Installing ",
"^Loading composer",
"^Updating dependencies",
]
match_output = [
{ pattern = "Nothing to install, update or remove", message = "ok (up to date)" },
]
max_lines = 30
[[tests.composer-install]]
name = "nothing to do short-circuits"
input = """
Loading composer repositories with package information
Updating dependencies
Lock file operations: 0 installs, 0 updates, 0 removals
Nothing to install, update or remove
Generating autoload files
"""
expected = "ok (up to date)"
[[tests.composer-install]]
name = "install strips download lines"
input = """
Loading composer repositories with package information
Updating dependencies
- Downloading symfony/console (v6.4.0)
- Installing symfony/console (v6.4.0): Extracting archive
- Downloading psr/log (3.0.0)
- Installing psr/log (3.0.0): Extracting archive
Writing lock file
Generating autoload files
"""
expected = "Writing lock file\nGenerating autoload files\n"
@@ -0,0 +1,19 @@
# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/df.toml
# match_command adapted for the local (program, subcommand) dispatch model.
[filters.df]
description = "Compact df output — truncate wide columns, limit rows"
match_command = "^df$"
strip_ansi = true
truncate_lines_at = 80
max_lines = 20
[[tests.df]]
name = "short output passes through unchanged"
input = "Filesystem 1K-blocks Used Available Use% Mounted on\n/dev/sda1 4096000 123456 3972544 4% /"
expected = "Filesystem 1K-blocks Used Available Use% Mounted on\n/dev/sda1 4096000 123456 3972544 4% /\n"
[[tests.df]]
name = "empty input passes through"
input = ""
expected = ""
@@ -0,0 +1,68 @@
# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/dotnet-build.toml
# match_command adapted for the local (program, subcommand) dispatch model.
[filters.dotnet-build]
description = "Compact dotnet build output — short-circuit on success, strip banners"
match_command = "^dotnet$"
match_subcommand = "^build$"
strip_ansi = true
strip_lines_matching = [
"^\\s*$",
"^Microsoft \\(R\\)",
"^Copyright \\(C\\)",
"^ Determining projects",
]
match_output = [
{ pattern = "0 Warning\\(s\\)\\n\\s+0 Error\\(s\\)", message = "ok (build succeeded)" },
]
max_lines = 40
[[tests.dotnet-build]]
name = "successful build short-circuits to ok"
input = """
Microsoft (R) Build Engine version 17.8.3+195e7f5a3
Copyright (C) Microsoft Corporation. All rights reserved.
Determining projects to restore...
All projects are up-to-date for restore.
MyApp -> /home/user/MyApp/bin/Debug/net8.0/MyApp.dll
Build succeeded.
0 Warning(s)
0 Error(s)
Time Elapsed 00:00:02.34
"""
expected = "ok (build succeeded)"
[[tests.dotnet-build]]
name = "build with warnings not short-circuited"
input = """
Microsoft (R) Build Engine version 17.8.3+195e7f5a3
Copyright (C) Microsoft Corporation. All rights reserved.
Determining projects to restore...
MyApp -> /home/user/MyApp/bin/Debug/net8.0/MyApp.dll
Build succeeded.
3 Warning(s)
0 Error(s)
Time Elapsed 00:00:01.87
"""
expected = " MyApp -> /home/user/MyApp/bin/Debug/net8.0/MyApp.dll\nBuild succeeded.\n 3 Warning(s)\n 0 Error(s)\nTime Elapsed 00:00:01.87\n"
[[tests.dotnet-build]]
name = "build errors pass through"
input = """
Microsoft (R) Build Engine version 17.8.3+195e7f5a3
Copyright (C) Microsoft Corporation. All rights reserved.
Determining projects to restore...
src/Program.cs(10,5): error CS1002: ; expected [/home/user/MyApp/MyApp.csproj]
Build FAILED.
0 Warning(s)
1 Error(s)
"""
expected = "src/Program.cs(10,5): error CS1002: ; expected [/home/user/MyApp/MyApp.csproj]\nBuild FAILED.\n 0 Warning(s)\n 1 Error(s)\n"
@@ -0,0 +1,19 @@
# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/du.toml
# match_command adapted for the local (program, subcommand) dispatch model.
[filters.du]
description = "Compact du output"
match_command = "^du$"
strip_lines_matching = ["^\\s*$"]
truncate_lines_at = 120
max_lines = 40
[[tests.du]]
name = "preserves sizes, strips blank lines"
input = "4.0K\t./src\n\n8.0K\t./tests\n16K\t."
expected = "4.0K\t./src\n8.0K\t./tests\n16K\t.\n"
[[tests.du]]
name = "single line passthrough"
input = "128K\t."
expected = "128K\t.\n"
@@ -0,0 +1,18 @@
# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/fail2ban-client.toml
# match_command adapted for the local (program, subcommand) dispatch model.
[filters.fail2ban-client]
description = "Compact fail2ban-client output"
match_command = "^fail2ban-client$"
strip_lines_matching = ["^\\s*$"]
max_lines = 30
[[tests.fail2ban-client]]
name = "strips blank lines"
input = "Status for the jail: sshd\n|- Filter\n| |- Currently failed: 3\n\n|- Actions\n `- Total banned: 42"
expected = "Status for the jail: sshd\n|- Filter\n| |- Currently failed: 3\n|- Actions\n `- Total banned: 42\n"
[[tests.fail2ban-client]]
name = "single line passthrough"
input = "Shutdown successful"
expected = "Shutdown successful\n"
@@ -0,0 +1,52 @@
# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/gcc.toml
# match_command adapted for the local (program, subcommand) dispatch model.
[filters.gcc]
description = "Compact gcc/g++ compiler output — strip notes, keep errors and warnings"
match_command = "^(gcc|g\\+\\+)$"
strip_ansi = true
strip_lines_matching = [
"^\\s*$",
"^\\s+\\|\\s*$",
"^In file included from",
"^\\s+from\\s",
"^\\d+ warnings? generated",
"^\\d+ errors? generated",
]
max_lines = 50
on_empty = "gcc: ok"
[[tests.gcc]]
name = "strips include chain, keeps errors and warnings"
input = """
In file included from /usr/include/stdio.h:42:
from main.c:1:
main.c:10:5: error: use of undeclared identifier 'foo'
foo();
^
main.c:15:12: warning: unused variable 'x' [-Wunused-variable]
int x = 42;
^
2 warnings generated.
1 error generated.
"""
expected = "main.c:10:5: error: use of undeclared identifier 'foo'\n foo();\n ^\nmain.c:15:12: warning: unused variable 'x' [-Wunused-variable]\n int x = 42;\n ^\n"
[[tests.gcc]]
name = "clean compilation"
input = """
"""
expected = "gcc: ok"
[[tests.gcc]]
name = "linker error kept"
input = """
/usr/bin/ld: /tmp/main.o: undefined reference to 'missing_func'
collect2: error: ld returned 1 exit status
"""
expected = "/usr/bin/ld: /tmp/main.o: undefined reference to 'missing_func'\ncollect2: error: ld returned 1 exit status\n"
[[tests.gcc]]
name = "empty input returns on_empty message"
input = ""
expected = "gcc: ok"
@@ -0,0 +1,27 @@
# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/hadolint.toml
# match_command adapted for the local (program, subcommand) dispatch model.
[filters.hadolint]
description = "Compact hadolint Dockerfile linting output"
match_command = "^hadolint$"
strip_ansi = true
strip_lines_matching = [
"^\\s*$",
]
truncate_lines_at = 120
max_lines = 40
[[tests.hadolint]]
name = "Dockerfile warnings kept, blank lines stripped"
input = """
Dockerfile:3 DL3008 warning: Pin versions in apt-get install
Dockerfile:5 DL3009 info: Delete apt-get lists after installing
Dockerfile:8 DL4006 warning: Set SHELL option -o pipefail before RUN with pipe
"""
expected = "Dockerfile:3 DL3008 warning: Pin versions in apt-get install\nDockerfile:5 DL3009 info: Delete apt-get lists after installing\nDockerfile:8 DL4006 warning: Set SHELL option -o pipefail before RUN with pipe\n"
[[tests.hadolint]]
name = "empty input passes through"
input = ""
expected = ""
@@ -0,0 +1,27 @@
# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/jq.toml
# match_command adapted for the local (program, subcommand) dispatch model.
[filters.jq]
description = "Compact jq output — truncate large JSON results"
match_command = "^jq$"
strip_ansi = true
strip_lines_matching = [
"^\\s*$",
]
max_lines = 40
truncate_lines_at = 120
[[tests.jq]]
name = "short output passes through"
input = """
{
"name": "test",
"version": "1.0"
}
"""
expected = "{\n \"name\": \"test\",\n \"version\": \"1.0\"\n}\n"
[[tests.jq]]
name = "empty input passes through"
input = ""
expected = ""
@@ -0,0 +1,44 @@
# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/make.toml
# match_command adapted for the local (program, subcommand) dispatch model.
[filters.make]
description = "Compact make output"
match_command = "^make$"
strip_lines_matching = [
"^make\\[\\d+\\]:",
"^\\s*$",
"^Nothing to be done",
]
max_lines = 50
on_empty = "make: ok"
[[tests.make]]
name = "strips entering/leaving lines"
input = """
make[1]: Entering directory '/home/user'
gcc -O2 foo.c
make[1]: Leaving directory '/home/user'
"""
expected = """
gcc -O2 foo.c
"""
[[tests.make]]
name = "strips blank lines"
input = """
gcc -O2 foo.c
gcc -O2 bar.c
"""
expected = """
gcc -O2 foo.c
gcc -O2 bar.c
"""
[[tests.make]]
name = "on_empty when all stripped"
input = """
make[1]: Entering directory '/home/user'
make[1]: Leaving directory '/home/user'
"""
expected = "make: ok"
@@ -0,0 +1,27 @@
# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/markdownlint.toml
# match_command adapted for the local (program, subcommand) dispatch model.
[filters.markdownlint]
description = "Compact markdownlint output — strip blank lines, limit rows"
match_command = "^markdownlint$"
strip_ansi = true
strip_lines_matching = [
"^\\s*$",
]
max_lines = 50
truncate_lines_at = 120
[[tests.markdownlint]]
name = "linting errors stripped of blank lines"
input = """
README.md:1:1 MD041/first-line-heading/first-line-h1 First line in file should be a top level heading
README.md:10:1 MD022/blanks-around-headings Headings should be surrounded by blank lines
README.md:15:80 MD013/line-length Line length [Expected: 80; Actual: 95]
"""
expected = "README.md:1:1 MD041/first-line-heading/first-line-h1 First line in file should be a top level heading\nREADME.md:10:1 MD022/blanks-around-headings Headings should be surrounded by blank lines\nREADME.md:15:80 MD013/line-length Line length [Expected: 80; Actual: 95]\n"
[[tests.markdownlint]]
name = "empty input passes through"
input = ""
expected = ""
@@ -0,0 +1,31 @@
# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/mix-compile.toml
# match_command adapted for the local (program, subcommand) dispatch model.
[filters.mix-compile]
description = "Compact mix compile output"
match_command = "^mix$"
match_subcommand = "^compile$"
strip_ansi = true
strip_lines_matching = [
"^Compiling \\d+ file",
"^\\s*$",
"^Generated\\s",
]
max_lines = 40
on_empty = "mix compile: ok"
[[tests.mix-compile]]
name = "strips compile noise, preserves warnings"
input = """
Compiling 12 files (.ex)
Generated my_app app
warning: variable "conn" is unused
lib/router.ex:42
"""
expected = "warning: variable \"conn\" is unused\n lib/router.ex:42\n"
[[tests.mix-compile]]
name = "on_empty when only noise"
input = "Compiling 3 files (.ex)\nGenerated my_app app\n"
expected = "mix compile: ok"
@@ -0,0 +1,19 @@
# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/mix-format.toml
# match_command adapted for the local (program, subcommand) dispatch model.
[filters.mix-format]
description = "Compact mix format output"
match_command = "^mix$"
match_subcommand = "^format$"
on_empty = "mix format: ok"
max_lines = 20
[[tests.mix-format]]
name = "empty output returns ok"
input = ""
expected = "mix format: ok"
[[tests.mix-format]]
name = "changed files pass through"
input = "lib/my_app.ex\ntest/my_app_test.exs"
expected = "lib/my_app.ex\ntest/my_app_test.exs"
@@ -0,0 +1,48 @@
# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/mvn-build.toml
# match_command adapted for the local (program, subcommand) dispatch model.
[filters.mvn-build]
description = "Compact Maven build output"
match_command = "^(mvn|mvnw)$"
match_subcommand = "^(compile|package|clean|install)$"
strip_ansi = true
strip_lines_matching = [
"^\\[INFO\\] ---",
"^\\[INFO\\] Building\\s",
"^\\[INFO\\] Downloading\\s",
"^\\[INFO\\] Downloaded\\s",
"^\\[INFO\\]\\s*$",
"^\\s*$",
"^Downloading:",
"^Downloaded:",
"^Progress",
]
max_lines = 50
on_empty = "mvn: ok"
[[tests.mvn-build]]
name = "strips INFO noise, preserves errors and summary"
input = """
[INFO] ---
[INFO] Building myapp 1.0-SNAPSHOT
[INFO] Downloading org.apache.maven.plugins:maven-compiler-plugin:3.11.0
[INFO] Downloaded org.apache.maven.plugins:maven-compiler-plugin:3.11.0
[INFO]
[ERROR] /src/main/java/Main.java:[10,5] cannot find symbol
symbol: method foo()
[INFO] BUILD FAILURE
[INFO] Total time: 2.543 s
"""
expected = "[ERROR] /src/main/java/Main.java:[10,5] cannot find symbol\n symbol: method foo()\n[INFO] BUILD FAILURE\n[INFO] Total time: 2.543 s\n"
[[tests.mvn-build]]
name = "successful build keeps BUILD SUCCESS line"
input = """
[INFO] ---
[INFO] Building myapp 1.0-SNAPSHOT
[INFO]
[INFO] BUILD SUCCESS
[INFO] Total time: 4.123 s
[INFO] Finished at: 2024-01-15T10:30:00Z
"""
expected = "[INFO] BUILD SUCCESS\n[INFO] Total time: 4.123 s\n[INFO] Finished at: 2024-01-15T10:30:00Z\n"
@@ -0,0 +1,46 @@
# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/oxlint.toml
# match_command adapted for the local (program, subcommand) dispatch model.
[filters.oxlint]
description = "Compact oxlint output — strip blank lines, keep diagnostics"
match_command = "^oxlint$"
strip_ansi = true
strip_lines_matching = [
"^\\s*$",
"^Finished in \\d+",
"^Found \\d+ warning",
]
max_lines = 50
on_empty = "oxlint: ok"
[[tests.oxlint]]
name = "strips noise, keeps diagnostics"
input = """
× eslint(no-console): Unexpected console statement.
╭─[src/app.ts:5:3]
5 │ console.log("debug");
│ ^^^^^^^^^^^
╰────
× eslint(no-unused-vars): 'x' is defined but never used.
╭─[src/utils.ts:2:7]
2 │ let x = 42;
│ ^
╰────
Found 2 warnings on 2 files.
Finished in 12ms on 100 files.
"""
expected = " × eslint(no-console): Unexpected console statement.\n ╭─[src/app.ts:5:3]\n 5 │ console.log(\"debug\");\n │ ^^^^^^^^^^^\n ╰────\n × eslint(no-unused-vars): 'x' is defined but never used.\n ╭─[src/utils.ts:2:7]\n 2 │ let x = 42;\n │ ^\n ╰────\n"
[[tests.oxlint]]
name = "clean output"
input = """
Finished in 5ms on 100 files.
"""
expected = "oxlint: ok"
[[tests.oxlint]]
name = "empty input returns on_empty message"
input = ""
expected = "oxlint: ok"
@@ -0,0 +1,44 @@
# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/pio-run.toml
# match_command adapted for the local (program, subcommand) dispatch model.
[filters.pio-run]
description = "Compact PlatformIO build output"
match_command = "^pio$"
match_subcommand = "^run$"
strip_ansi = true
strip_lines_matching = [
"^\\s*$",
"^Verbose mode",
"^CONFIGURATION:",
"^LDF:",
"^Library Manager:",
"^Compiling\\s",
"^Linking\\s",
"^Building\\s",
"^Checking size",
]
max_lines = 30
on_empty = "pio run: ok"
[[tests.pio-run]]
name = "strips build noise, preserves errors"
input = """
Verbose mode can be enabled via `-v, --verbose` option
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32dev.html
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
Compiling .pio/build/esp32dev/src/main.cpp.o
Building .pio/build/esp32dev/firmware.elf
Linking .pio/build/esp32dev/firmware.elf
Checking size .pio/build/esp32dev/firmware.elf
src/main.cpp:10:3: error: 'LED_BUILTINN' was not declared
"""
expected = "src/main.cpp:10:3: error: 'LED_BUILTINN' was not declared\n"
[[tests.pio-run]]
name = "on_empty when clean build with only noise"
input = """
Verbose mode can be enabled via `-v, --verbose` option
Compiling .pio/build/esp32dev/src/main.cpp.o
Linking .pio/build/esp32dev/firmware.elf
"""
expected = "pio run: ok"
@@ -0,0 +1,54 @@
# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/poetry-install.toml
# match_command adapted for the local (program, subcommand) dispatch model.
[filters.poetry-install]
description = "Compact poetry install/lock/update output — strip downloads, short-circuit when up-to-date"
match_command = "^poetry$"
match_subcommand = "^(install|lock|update)$"
strip_ansi = true
strip_lines_matching = [
"^\\s*$",
"^ [-•] Downloading ",
"^ [-•] Installing .* \\(",
"^Creating virtualenv",
"^Using virtualenv",
]
match_output = [
{ pattern = "No dependencies to install or update|No changes\\.", message = "ok (up to date)" },
]
max_lines = 30
[[tests.poetry-install]]
name = "up to date short-circuits"
input = """
Installing dependencies from lock file
No dependencies to install or update
"""
expected = "ok (up to date)"
[[tests.poetry-install]]
name = "poetry 2.x bullet syntax short-circuits to ok"
input = """
• Installing requests (2.31.0)
• Installing certifi (2023.11.17)
No changes.
"""
expected = "ok (up to date)"
[[tests.poetry-install]]
name = "install strips download lines"
input = """
Installing dependencies from lock file
- Downloading requests-2.31.0-py3-none-any.whl (62.6 kB)
- Installing certifi (2023.11.17)
- Installing charset-normalizer (3.3.2)
- Installing idna (3.6)
- Installing urllib3 (2.1.0)
- Installing requests (2.31.0)
Writing lock file
"""
expected = "Installing dependencies from lock file\nWriting lock file\n"
@@ -0,0 +1,45 @@
# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/quarto-render.toml
# match_command adapted for the local (program, subcommand) dispatch model.
[filters.quarto-render]
description = "Compact quarto render output"
match_command = "^quarto$"
match_subcommand = "^render$"
strip_ansi = true
strip_lines_matching = [
"^\\s*$",
"^\\s*processing file:",
"^\\s*\\d+/\\d+\\s",
"^\\s*running",
"^\\s*Rendering",
"^pandoc ",
"^ Validating",
"^ Resolving",
]
match_output = [
{ pattern = "Output created:", message = "ok (output created)" },
]
max_lines = 20
[[tests.quarto-render]]
name = "success short-circuits to ok"
input = """
processing file: index.qmd
Validating schema
Resolving resources
pandoc to html5
Output created: _site/index.html
"""
expected = "ok (output created)"
[[tests.quarto-render]]
name = "error passes through"
input = """
processing file: broken.qmd
Validating schema
ERROR: Render failed
caused by:
syntax error at line 10
"""
expected = "ERROR: Render failed\ncaused by:\n syntax error at line 10\n"
@@ -0,0 +1,30 @@
# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/shellcheck.toml
# match_command adapted for the local (program, subcommand) dispatch model.
[filters.shellcheck]
description = "Compact shellcheck output — strip blank lines, keep caret indicators for error position"
match_command = "^shellcheck$"
strip_ansi = true
strip_lines_matching = [
"^\\s*$",
]
max_lines = 50
[[tests.shellcheck]]
name = "multi-warning output stripped of blank lines only"
input = """
In script.sh line 3:
if [[ $1 == "" ]]
^-- SC2236: Use -z instead of ! -n.
In script.sh line 7:
echo $var
^-- SC2086: Double quote to prevent globbing.
"""
expected = "In script.sh line 3:\nif [[ $1 == \"\" ]]\n ^-- SC2236: Use -z instead of ! -n.\nIn script.sh line 7:\necho $var\n ^-- SC2086: Double quote to prevent globbing.\n"
[[tests.shellcheck]]
name = "empty input passes through"
input = ""
expected = ""
@@ -0,0 +1,47 @@
# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/stat.toml
# match_command adapted for the local (program, subcommand) dispatch model.
[filters.stat]
description = "Compact stat output — strip device/inode/birth noise"
match_command = "^stat$"
strip_ansi = true
strip_lines_matching = [
"^\\s*$",
"^\\s*Device:",
"^\\s*Birth:",
]
truncate_lines_at = 120
max_lines = 20
[[tests.stat]]
name = "linux stat output strips device and birth"
input = """
File: main.rs
Size: 12345 Blocks: 24 IO Block: 4096 regular file
Device: 801h/2049d Inode: 1234567 Links: 1
Access: (0644/-rw-r--r--) Uid: ( 1000/ patrick) Gid: ( 1000/ patrick)
Access: 2026-03-10 12:00:00.000000000 +0100
Modify: 2026-03-10 11:00:00.000000000 +0100
Change: 2026-03-10 11:00:00.000000000 +0100
Birth: 2026-03-09 10:00:00.000000000 +0100
"""
expected = " File: main.rs\n Size: 12345 Blocks: 24 IO Block: 4096 regular file\nAccess: (0644/-rw-r--r--) Uid: ( 1000/ patrick) Gid: ( 1000/ patrick)\nAccess: 2026-03-10 12:00:00.000000000 +0100\nModify: 2026-03-10 11:00:00.000000000 +0100\nChange: 2026-03-10 11:00:00.000000000 +0100\n"
[[tests.stat]]
name = "macOS stat -x strips device and birth"
input = """
File: "main.rs"
Size: 82848 FileType: Regular File
Mode: (0644/-rw-r--r--) Uid: ( 501/ patrick) Gid: ( 20/ staff)
Device: 1,15 Inode: 66302332 Links: 1
Access: Wed Mar 18 21:21:15 2026
Modify: Wed Mar 18 20:56:11 2026
Change: Wed Mar 18 20:56:11 2026
Birth: Wed Mar 18 20:56:11 2026
"""
expected = " File: \"main.rs\"\n Size: 82848 FileType: Regular File\n Mode: (0644/-rw-r--r--) Uid: ( 501/ patrick) Gid: ( 20/ staff)\nAccess: Wed Mar 18 21:21:15 2026\nModify: Wed Mar 18 20:56:11 2026\nChange: Wed Mar 18 20:56:11 2026\n"
[[tests.stat]]
name = "empty input passes through"
input = ""
expected = ""
@@ -0,0 +1,37 @@
# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/systemctl-status.toml
# match_command adapted for the local (program, subcommand) dispatch model.
[filters.systemctl-status]
description = "Compact systemctl status output — strip blank lines, limit to 20 lines"
match_command = "^systemctl$"
match_subcommand = "^status$"
strip_ansi = true
strip_lines_matching = [
"^\\s*$",
]
max_lines = 20
[[tests.systemctl-status]]
name = "verbose unit status stripped of blank lines"
input = """
● nginx.service - A high performance web server
Loaded: loaded (/lib/systemd/system/nginx.service; enabled)
Active: active (running) since Mon 2024-01-15 10:30:00 UTC; 2h ago
Docs: man:nginx(8)
Main PID: 1234 (nginx)
Tasks: 3 (limit: 4915)
Memory: 8.5M
CGroup: /system.slice/nginx.service
├─1234 nginx: master process /usr/sbin/nginx
└─1235 nginx: worker process
Jan 15 10:30:00 host nginx[1234]: nginx/1.24.0
Jan 15 10:30:00 host systemd[1]: Started nginx.service
"""
expected = "● nginx.service - A high performance web server\n Loaded: loaded (/lib/systemd/system/nginx.service; enabled)\n Active: active (running) since Mon 2024-01-15 10:30:00 UTC; 2h ago\n Docs: man:nginx(8)\n Main PID: 1234 (nginx)\n Tasks: 3 (limit: 4915)\n Memory: 8.5M\n CGroup: /system.slice/nginx.service\n ├─1234 nginx: master process /usr/sbin/nginx\n └─1235 nginx: worker process\nJan 15 10:30:00 host nginx[1234]: nginx/1.24.0\nJan 15 10:30:00 host systemd[1]: Started nginx.service\n"
[[tests.systemctl-status]]
name = "empty input passes through"
input = ""
expected = ""
@@ -0,0 +1,39 @@
# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/terraform-plan.toml
# match_command adapted for the local (program, subcommand) dispatch model.
[filters.terraform-plan]
description = "Compact Terraform plan output"
match_command = "^terraform$"
match_subcommand = "^plan$"
strip_ansi = true
strip_lines_matching = [
"^Refreshing state",
"^\\s*#.*unchanged",
"^\\s*$",
"^Acquiring state lock",
"^Releasing state lock",
]
max_lines = 80
on_empty = "terraform plan: no changes detected"
[[tests.terraform-plan]]
name = "strips Refreshing state lines and blank lines"
input = """
Acquiring state lock. This may take a few moments...
Refreshing state... [id=vpc-abc]
Refreshing state... [id=sg-123]
Releasing state lock. This may take a few moments...
Terraform will perform the following actions:
# aws_instance.web will be created
+ resource "aws_instance" "web" {}
Plan: 1 to add, 0 to change, 0 to destroy.
"""
expected = "Terraform will perform the following actions:\n # aws_instance.web will be created\n + resource \"aws_instance\" \"web\" {}\nPlan: 1 to add, 0 to change, 0 to destroy.\n"
[[tests.terraform-plan]]
name = "strips noise, preserves non-blank content"
input = "Refreshing state... [id=vpc-abc]\nNo changes. Your infrastructure matches the configuration."
expected = "No changes. Your infrastructure matches the configuration.\n"
@@ -0,0 +1,20 @@
# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/tofu-fmt.toml
# match_command adapted for the local (program, subcommand) dispatch model.
[filters.tofu-fmt]
description = "Compact OpenTofu fmt output"
match_command = "^tofu$"
match_subcommand = "^fmt$"
strip_ansi = true
on_empty = "tofu fmt: ok (no changes)"
max_lines = 30
[[tests.tofu-fmt]]
name = "empty output returns on_empty message"
input = ""
expected = "tofu fmt: ok (no changes)"
[[tests.tofu-fmt]]
name = "changed files pass through"
input = "main.tf\nvariables.tf"
expected = "main.tf\nvariables.tf"
@@ -0,0 +1,42 @@
# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/tofu-init.toml
# match_command adapted for the local (program, subcommand) dispatch model.
[filters.tofu-init]
description = "Compact OpenTofu init output"
match_command = "^tofu$"
match_subcommand = "^init$"
strip_ansi = true
strip_lines_matching = [
"^- Downloading",
"^- Installing",
"^- Using previously-installed",
"^\\s*$",
"^Initializing provider",
"^Initializing the backend",
"^Initializing modules",
]
max_lines = 20
on_empty = "tofu init: ok"
[[tests.tofu-init]]
name = "strips downloading/installing lines"
input = """
Initializing the backend...
Initializing provider plugins...
- Downloading hashicorp/aws 5.0.0...
- Installing hashicorp/aws 5.0.0...
- Using previously-installed hashicorp/random 3.5.1
OpenTofu has been successfully initialized!
"""
expected = "OpenTofu has been successfully initialized!\n"
[[tests.tofu-init]]
name = "on_empty when all noise stripped"
input = """
Initializing the backend...
Initializing provider plugins...
- Using previously-installed hashicorp/aws 5.0.0
"""
expected = "tofu init: ok"
@@ -0,0 +1,39 @@
# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/tofu-plan.toml
# match_command adapted for the local (program, subcommand) dispatch model.
[filters.tofu-plan]
description = "Compact OpenTofu plan output"
match_command = "^tofu$"
match_subcommand = "^plan$"
strip_ansi = true
strip_lines_matching = [
"^Refreshing state",
"^\\s*#.*unchanged",
"^\\s*$",
"^Acquiring state lock",
"^Releasing state lock",
]
max_lines = 80
on_empty = "tofu plan: no changes detected"
[[tests.tofu-plan]]
name = "strips Refreshing state and lock lines"
input = """
Acquiring state lock. This may take a few moments...
Refreshing state... [id=vpc-abc123]
Refreshing state... [id=sg-def456]
Releasing state lock. This may take a few moments...
OpenTofu will perform the following actions:
# aws_instance.web will be created
+ resource "aws_instance" "web" {}
Plan: 1 to add, 0 to change, 0 to destroy.
"""
expected = "OpenTofu will perform the following actions:\n # aws_instance.web will be created\n + resource \"aws_instance\" \"web\" {}\nPlan: 1 to add, 0 to change, 0 to destroy.\n"
[[tests.tofu-plan]]
name = "on_empty when all noise stripped"
input = "Refreshing state... [id=vpc-abc]\nAcquiring state lock. This may take a few moments...\nReleasing state lock. This may take a few moments..."
expected = "tofu plan: no changes detected"
@@ -0,0 +1,21 @@
# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/tofu-validate.toml
# match_command adapted for the local (program, subcommand) dispatch model.
[filters.tofu-validate]
description = "Compact OpenTofu validate output"
match_command = "^tofu$"
match_subcommand = "^validate$"
strip_ansi = true
match_output = [
{ pattern = "Success! The configuration is valid", message = "ok (valid)" },
]
[[tests.tofu-validate]]
name = "success short-circuits to ok"
input = "Success! The configuration is valid."
expected = "ok (valid)"
[[tests.tofu-validate]]
name = "error passes through unchanged"
input = "Error: Invalid resource type\n on main.tf line 3: resource \"aws_instancee\" \"web\""
expected = "Error: Invalid resource type\n on main.tf line 3: resource \"aws_instancee\" \"web\""
@@ -0,0 +1,43 @@
# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/trunk-build.toml
# match_command adapted for the local (program, subcommand) dispatch model.
[filters.trunk-build]
description = "Compact trunk build output"
match_command = "^trunk$"
match_subcommand = "^build$"
strip_ansi = true
strip_lines_matching = [
"^\\s*$",
"^\\s*Compiling\\s",
"^\\s*Downloading\\s",
"^\\s*Fetching\\s",
"^\\s*Fresh\\s",
"^\\s*Checking\\s",
]
tail_lines = 10
max_lines = 30
on_empty = "trunk build: ok"
[[tests.trunk-build]]
name = "strips compile noise, keeps tail summary"
input = """
Compiling tokio v1.35.0
Compiling hyper v0.14.28
Compiling my-crate v0.1.0
Downloading serde v1.0.195
Fresh regex v1.10.2
Finished release [optimized] target(s) in 45.23s
Binary: target/release/my-crate (5.2MB)
"""
expected = " Finished release [optimized] target(s) in 45.23s\n Binary: target/release/my-crate (5.2MB)\n"
[[tests.trunk-build]]
name = "on_empty when all noise stripped"
input = """
Compiling my-crate v0.1.0
Fresh serde v1.0
Checking tokio v1.35.0
"""
expected = "trunk build: ok"
@@ -0,0 +1,41 @@
# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/uv-sync.toml
# match_command adapted for the local (program, subcommand) dispatch model.
[filters.uv-sync]
description = "Compact uv sync/pip install output — strip downloads, short-circuit when up-to-date"
match_command = "^uv$"
match_subcommand = "^(sync|pip)$"
strip_ansi = true
strip_lines_matching = [
"^\\s*$",
"^\\s+Downloading ",
"^\\s+Using cached ",
"^\\s+Preparing ",
]
match_output = [
{ pattern = "Audited \\d+ package", message = "ok (up to date)" },
]
max_lines = 20
[[tests.uv-sync]]
name = "audited packages short-circuits to ok"
input = """
Resolved 42 packages in 123ms
Audited 42 packages in 0.05ms
"""
expected = "ok (up to date)"
[[tests.uv-sync]]
name = "install strips download and cached lines"
input = """
Downloading requests-2.31.0-py3-none-any.whl (62.6 kB)
Using cached certifi-2023.11.17-py3-none-any.whl (162 kB)
Preparing packages...
Installed 5 packages in 23ms
+ certifi==2023.11.17
+ charset-normalizer==3.3.2
+ idna==3.6
+ requests==2.31.0
+ urllib3==2.1.0
"""
expected = "Installed 5 packages in 23ms\n + certifi==2023.11.17\n + charset-normalizer==3.3.2\n + idna==3.6\n + requests==2.31.0\n + urllib3==2.1.0\n"
@@ -0,0 +1,28 @@
# Ported from https://github.com/rtk-ai/rtk/blob/master/src/filters/yamllint.toml
# match_command adapted for the local (program, subcommand) dispatch model.
[filters.yamllint]
description = "Compact yamllint output — strip blank lines, limit rows"
match_command = "^yamllint$"
strip_ansi = true
strip_lines_matching = [
"^\\s*$",
]
max_lines = 50
truncate_lines_at = 120
[[tests.yamllint]]
name = "multi-warning output stripped of blank lines"
input = """
config.yml
3:1 warning missing document start "---" (document-start)
5:12 error too many spaces inside braces (braces)
8:1 error wrong indentation: expected 2 but found 4 (indentation)
"""
expected = "config.yml\n 3:1 warning missing document start \"---\" (document-start)\n 5:12 error too many spaces inside braces (braces)\n 8:1 error wrong indentation: expected 2 but found 4 (indentation)\n"
[[tests.yamllint]]
name = "empty input passes through"
input = ""
expected = ""
@@ -414,7 +414,7 @@ mod tests {
assert!(should_minimize("git status", &cfg));
let out = apply("git status", "## main\n M file.rs\n", 0, &cfg);
assert!(out.changed);
assert!(out.text.contains("unstaged: 1"));
assert!(out.text.contains("modified: 1"));
}
#[test]
@@ -458,7 +458,7 @@ mod tests {
assert_eq!(out.filter, "compound");
assert_eq!(out.original_text.as_deref(), Some("before\n## main\n M file.rs\nafter\n"));
assert!(out.text.contains("before\n"));
assert!(out.text.contains("unstaged: 1"));
assert!(out.text.contains("modified: 1"));
assert!(out.text.contains("after\n"));
}
@@ -61,7 +61,7 @@ fn is_compiling_noise(line: &str) -> bool {
fn failures_only(input: &str, exit_code: i32) -> String {
if exit_code == 0 {
return strip_passing_tests(input);
return summarize_successful_test_run(input);
}
let mut out = String::new();
let mut keep = false;
@@ -89,6 +89,119 @@ fn failures_only(input: &str, exit_code: i32) -> String {
}
}
#[derive(Default)]
struct CargoTestTotals {
suites: usize,
passed: u64,
failed: u64,
ignored: u64,
measured: u64,
filtered: u64,
warnings: u64,
duration: Option<String>,
}
fn summarize_successful_test_run(input: &str) -> String {
let mut totals = CargoTestTotals::default();
for line in input.lines() {
let trimmed = line.trim();
if let Some(summary) = trimmed.strip_prefix("test result: ok.") {
totals.suites += 1;
collect_cargo_test_summary(summary, &mut totals);
continue;
}
if let Some(warnings) = parse_generated_warning_count(trimmed) {
totals.warnings += warnings;
}
}
if totals.suites == 0 {
return strip_passing_tests(input);
}
let mut out = String::from("cargo test:");
if totals.passed > 0 {
out.push(' ');
out.push_str(&totals.passed.to_string());
out.push_str(" passed");
} else {
out.push_str(" ok");
}
let mut details = Vec::new();
details.push(format_suite_count(totals.suites));
if totals.failed > 0 {
details.push(format!("{} failed", totals.failed));
}
if totals.ignored > 0 {
details.push(format!("{} ignored", totals.ignored));
}
if totals.measured > 0 {
details.push(format!("{} measured", totals.measured));
}
if totals.filtered > 0 {
details.push(format!("{} filtered", totals.filtered));
}
if totals.warnings > 0 {
details.push(format!("{} warnings", totals.warnings));
}
if let Some(duration) = totals.duration {
details.push(duration);
}
if !details.is_empty() {
out.push_str(" (");
out.push_str(&details.join(", "));
out.push(')');
}
out.push('\n');
out
}
fn collect_cargo_test_summary(summary: &str, totals: &mut CargoTestTotals) {
for part in summary.split(';') {
let trimmed = part.trim().trim_end_matches('.');
if let Some(value) = parse_count_prefix(trimmed, "passed") {
totals.passed += value;
} else if let Some(value) = parse_count_prefix(trimmed, "failed") {
totals.failed += value;
} else if let Some(value) = parse_count_prefix(trimmed, "ignored") {
totals.ignored += value;
} else if let Some(value) = parse_count_prefix(trimmed, "measured") {
totals.measured += value;
} else if let Some(value) = parse_count_prefix(trimmed, "filtered out") {
totals.filtered += value;
} else if let Some(duration) = trimmed.strip_prefix("finished in ") {
totals.duration = Some(duration.to_string());
}
}
}
fn parse_generated_warning_count(line: &str) -> Option<u64> {
if !line.contains(" generated ") || !line.ends_with(" warnings") {
return None;
}
let before = line.rsplit_once(" warnings")?.0;
let count_text = before.rsplit_once(' ')?.1;
count_text.parse().ok()
}
fn parse_count_prefix(text: &str, label: &str) -> Option<u64> {
let (count, rest) = text.split_once(' ')?;
if rest != label {
return None;
}
count.parse().ok()
}
fn format_suite_count(suites: usize) -> String {
if suites == 1 {
"1 suite".to_string()
} else {
format!("{suites} suites")
}
}
fn strip_passing_tests(input: &str) -> String {
let mut out = String::new();
for line in input.lines() {
@@ -203,6 +316,15 @@ mod tests {
assert_eq!(out, "running 2 tests\ntest result: ok\n");
}
#[test]
fn summarizes_successful_cargo_test_run() {
let input = "warning: unused variable: `start`\nwarning: `rtk` (bin \"rtk\" test) generated \
17 warnings\nrunning 262 tests\ntest a ... ok\ntest b ... ok\ntest result: ok. \
262 passed; 0 failed; 0 ignored; 0 measured\n";
let out = summarize_successful_test_run(input);
assert_eq!(out, "cargo test: 262 passed (1 suite, 17 warnings)\n");
}
#[test]
fn supports_nextest_and_keeps_failures_with_summary() {
assert!(supports(Some("nextest")));
@@ -1,6 +1,6 @@
//! Git output filters.
use std::collections::{BTreeMap, btree_map::Entry};
use std::collections::BTreeMap;
use crate::shell::minimizer::{MinimizerCtx, MinimizerOutput, primitives};
@@ -37,7 +37,7 @@ pub fn filter(ctx: &MinimizerCtx<'_>, input: &str, _exit_code: i32) -> Minimizer
let cleaned = primitives::strip_ansi(input);
let text = match ctx.subcommand {
Some("status") => condense_status(&cleaned),
Some("diff") => cleaned,
Some("diff") => condense_diff(&cleaned),
Some("show") => primitives::head_tail_lines(&cleaned, 80, 40),
Some("log") => condense_log(&cleaned, 32, 16),
Some("branch" | "stash" | "tag") => primitives::compact_listing(&cleaned, 40),
@@ -55,17 +55,6 @@ pub fn filter(ctx: &MinimizerCtx<'_>, input: &str, _exit_code: i32) -> Minimizer
}
}
#[derive(Default)]
struct StatusTreeNode {
children: BTreeMap<String, Self>,
leaves: Vec<StatusLeaf>,
}
struct StatusLeaf {
status: String,
name: String,
}
struct StatusEntry {
status: String,
path: String,
@@ -125,24 +114,42 @@ fn condense_status(input: &str) -> String {
}
let mut counts = StatusCounts::default();
let mut tree = StatusTreeNode::default();
for entry in &entries {
count_status(&entry.status, &mut counts);
insert_status_path(&mut tree, &entry.status, &entry.path);
}
let mut out = String::from("git status summary");
let mut groups: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
for entry in &entries {
groups
.entry(status_group_label(&entry.status))
.or_default()
.push(&entry.path);
}
let mut out = String::from("git status");
if let Some(branch) = branch {
out.push_str(" on ");
out.push_str(": ");
out.push_str(branch);
}
out.push('\n');
push_count(&mut out, "staged", counts.staged);
push_count(&mut out, "unstaged", counts.unstaged);
push_count(&mut out, "modified", counts.unstaged);
push_count(&mut out, "untracked", counts.untracked);
push_count(&mut out, "conflicts", counts.conflicts);
out.push_str("paths:\n");
render_status_tree(&tree, &mut out, 0);
for (label, paths) in groups {
out.push_str(label);
out.push_str(":\n");
for path in paths.iter().take(24) {
out.push_str(" ");
out.push_str(path);
out.push('\n');
}
if paths.len() > 24 {
out.push_str(" … ");
out.push_str(&(paths.len() - 24).to_string());
out.push_str(" more\n");
}
}
out
}
@@ -208,49 +215,21 @@ fn count_status(status: &str, counts: &mut StatusCounts) {
}
}
fn insert_status_path(root: &mut StatusTreeNode, status: &str, path: &str) {
let parts: Vec<&str> = path.split('/').filter(|part| !part.is_empty()).collect();
insert_status_parts(root, status, &parts);
}
fn insert_status_parts(node: &mut StatusTreeNode, status: &str, parts: &[&str]) {
if parts.is_empty() {
return;
fn status_group_label(status: &str) -> &'static str {
if status == "??" {
return "Untracked";
}
if parts.len() == 1 {
node
.leaves
.push(StatusLeaf { status: status.to_string(), name: parts[0].to_string() });
return;
if status.contains('U') {
return "Conflicts";
}
let child = match node.children.entry(parts[0].to_string()) {
Entry::Occupied(entry) => entry.into_mut(),
Entry::Vacant(entry) => entry.insert(StatusTreeNode::default()),
};
insert_status_parts(child, status, &parts[1..]);
}
fn render_status_tree(node: &StatusTreeNode, out: &mut String, depth: usize) {
for (name, child) in &node.children {
push_status_indent(out, depth);
out.push_str(name);
out.push_str("/\n");
render_status_tree(child, out, depth + 1);
let mut chars = status.chars();
if matches!(chars.next(), Some('M' | 'A' | 'D' | 'R' | 'C')) {
return "Staged";
}
for leaf in &node.leaves {
push_status_indent(out, depth);
out.push('[');
out.push_str(&leaf.status);
out.push_str("] ");
out.push_str(&leaf.name);
out.push('\n');
}
}
fn push_status_indent(out: &mut String, depth: usize) {
for _ in 0..depth {
out.push_str(" ");
if matches!(chars.next(), Some('M' | 'D')) {
return "Modified";
}
"Changed"
}
fn push_count(out: &mut String, label: &str, count: usize) {
@@ -299,6 +278,27 @@ fn has_token(command: &str, token: &str) -> bool {
}
fn condense_log(input: &str, head: usize, tail: usize) -> String {
let entries = parse_log_entries(input);
if !entries.is_empty() {
let mut out = String::new();
if entries.len() <= head + tail {
for entry in &entries {
push_log_entry(&mut out, entry);
}
} else {
for entry in entries.iter().take(head) {
push_log_entry(&mut out, entry);
}
out.push_str("… ");
out.push_str(&(entries.len() - head - tail).to_string());
out.push_str(" commits omitted …\n");
for entry in entries.iter().skip(entries.len() - tail) {
push_log_entry(&mut out, entry);
}
}
return out;
}
let mut out = String::new();
for line in input.lines() {
if let Some(commit) = line.strip_prefix("commit ") {
@@ -319,6 +319,240 @@ fn condense_log(input: &str, head: usize, tail: usize) -> String {
primitives::head_tail_lines(&out, head, tail)
}
struct LogEntry {
hash: String,
subject: String,
}
fn push_log_entry(out: &mut String, entry: &LogEntry) {
out.push_str(&entry.hash);
if !entry.subject.is_empty() {
out.push(' ');
out.push_str(&entry.subject);
}
out.push('\n');
}
fn parse_log_entries(input: &str) -> Vec<LogEntry> {
let mut entries = Vec::new();
let mut current: Option<LogEntry> = None;
for line in input.lines() {
if let Some(rest) = line.strip_prefix("commit ") {
if let Some(entry) = current.take() {
entries.push(entry);
}
let trimmed = rest.trim();
let (hash, subject) = trimmed
.split_once(' ')
.map_or((trimmed, ""), |(hash, subject)| (hash, subject.trim()));
current = Some(LogEntry { hash: short_hash(hash), subject: subject.to_string() });
continue;
}
let Some(entry) = current.as_mut() else {
continue;
};
if !entry.subject.is_empty() {
continue;
}
let trimmed = line.trim();
if trimmed.is_empty()
|| trimmed.starts_with("Author:")
|| trimmed.starts_with("Date:")
|| trimmed.starts_with("Merge:")
|| trimmed.contains('|')
|| trimmed.contains("files changed")
|| trimmed.contains("file changed")
{
continue;
}
entry.subject = trimmed.to_string();
}
if let Some(entry) = current {
entries.push(entry);
}
entries
}
fn short_hash(hash: &str) -> String {
hash.chars().take(7).collect()
}
struct DiffFile {
path: String,
added: usize,
removed: usize,
hunks: Vec<DiffHunk>,
}
struct DiffHunk {
header: String,
lines: Vec<String>,
}
fn condense_diff(input: &str) -> String {
let files = parse_unified_diff(input);
if files.is_empty() {
return input.to_string();
}
let total_added: usize = files.iter().map(|file| file.added).sum();
let total_removed: usize = files.iter().map(|file| file.removed).sum();
if total_added == 0 && total_removed == 0 {
return input.to_string();
}
let mut out = String::new();
for file in files.iter().take(20) {
let changed = file.added + file.removed;
out.push_str(&file.path);
out.push_str(" | ");
out.push_str(&changed.to_string());
out.push(' ');
out.push_str(&diff_bar(file.added, file.removed));
out.push('\n');
}
if files.len() > 20 {
out.push_str("… ");
out.push_str(&(files.len() - 20).to_string());
out.push_str(" files omitted from stat\n");
}
out.push_str(&format_file_count(files.len()));
out.push_str(" changed, ");
out.push_str(&total_added.to_string());
out.push_str(" insertions(+), ");
out.push_str(&total_removed.to_string());
out.push_str(" deletions(-)\n\n--- Changes ---\n");
for file in files.iter().take(12) {
out.push('\n');
out.push_str("File: ");
out.push_str(&file.path);
out.push('\n');
for hunk in file.hunks.iter().take(8) {
out.push_str(" ");
out.push_str(&hunk.header);
out.push('\n');
for line in hunk.lines.iter().take(6) {
out.push_str(" ");
out.push_str(line);
out.push('\n');
}
if hunk.lines.len() > 6 {
out.push_str(" … ");
out.push_str(&(hunk.lines.len() - 6).to_string());
out.push_str(" changed lines omitted\n");
}
}
if file.hunks.len() > 8 {
out.push_str(" … ");
out.push_str(&(file.hunks.len() - 8).to_string());
out.push_str(" hunks omitted\n");
}
}
if files.len() > 12 {
out.push_str("\n… ");
out.push_str(&(files.len() - 12).to_string());
out.push_str(" files omitted from changes\n");
}
out
}
fn parse_unified_diff(input: &str) -> Vec<DiffFile> {
let mut files = Vec::new();
let mut current: Option<DiffFile> = None;
let mut current_hunk: Option<DiffHunk> = None;
for line in input.lines() {
if let Some(path) = parse_diff_git_path(line) {
flush_hunk(&mut current, &mut current_hunk);
if let Some(file) = current.take() {
files.push(file);
}
current = Some(DiffFile { path, added: 0, removed: 0, hunks: Vec::new() });
continue;
}
if let Some(path) = line.strip_prefix("+++ b/") {
if let Some(file) = current.as_mut() {
file.path = path.to_string();
}
continue;
}
if line.starts_with("@@") {
flush_hunk(&mut current, &mut current_hunk);
current_hunk = Some(DiffHunk { header: line.to_string(), lines: Vec::new() });
continue;
}
if line.starts_with("+++") || line.starts_with("---") {
continue;
}
let Some(file) = current.as_mut() else {
continue;
};
if line.starts_with('+') {
file.added += 1;
push_diff_line(&mut current_hunk, line);
} else if line.starts_with('-') {
file.removed += 1;
push_diff_line(&mut current_hunk, line);
}
}
flush_hunk(&mut current, &mut current_hunk);
if let Some(file) = current {
files.push(file);
}
files
.into_iter()
.filter(|file| file.added > 0 || file.removed > 0)
.collect()
}
fn parse_diff_git_path(line: &str) -> Option<String> {
let rest = line.strip_prefix("diff --git ")?;
let mut parts = rest.split_whitespace();
let _old = parts.next()?;
let new = parts.next()?;
Some(new.strip_prefix("b/").map_or(new, |path| path).to_string())
}
fn flush_hunk(file: &mut Option<DiffFile>, hunk: &mut Option<DiffHunk>) {
let Some(hunk) = hunk.take() else {
return;
};
if let Some(file) = file.as_mut() {
file.hunks.push(hunk);
}
}
fn push_diff_line(hunk: &mut Option<DiffHunk>, line: &str) {
let Some(hunk) = hunk.as_mut() else {
return;
};
hunk.lines.push(primitives::truncate_line(line, 160));
}
fn diff_bar(added: usize, removed: usize) -> String {
let total = added + removed;
if total == 0 {
return String::new();
}
let width = total.clamp(1, 24);
let plus = (added * width).div_ceil(total);
let minus = width.saturating_sub(plus);
format!("{}{}", "+".repeat(plus), "-".repeat(minus))
}
fn format_file_count(files: usize) -> String {
if files == 1 {
"1 file".to_string()
} else {
format!("{files} files")
}
}
fn condense_noisy_output(input: &str) -> String {
let deduped = primitives::dedup_consecutive_lines(input);
primitives::head_tail_lines(&deduped, 80, 40)
@@ -338,7 +572,7 @@ mod tests {
}
#[test]
fn status_compacts_paths_into_tree() {
fn status_compacts_paths_into_groups() {
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
let ctx = test_ctx(Some("status"), "git status --short", &cfg);
let input = "## main\n M packages/agent/lib/agent.ts\n M \
@@ -346,16 +580,21 @@ mod tests {
crates/pi-natives/src/shell/minimizer/filters/git.rs\n";
let out = filter(&ctx, input, 0);
assert!(out.changed);
assert!(out.text.contains("git status summary on main"));
assert!(out.text.contains("unstaged: 2"));
assert!(out.text.contains("git status: main"));
assert!(out.text.contains("modified: 2"));
assert!(out.text.contains("untracked: 1"));
assert!(out.text.contains("packages/\n agent/\n"));
assert!(out.text.contains(" lib/\n [ M] agent.ts\n"));
assert!(
out.text
.contains(" tests/\n [ M] session-restore.test.ts\n")
.contains("Modified:\n packages/agent/lib/agent.ts")
);
assert!(
out.text
.contains(" packages/agent/tests/session-restore.test.ts")
);
assert!(
out.text
.contains("Untracked:\n crates/pi-natives/src/shell/minimizer/filters/git.rs")
);
assert!(out.text.contains("crates/\n pi-natives/\n"));
}
#[test]
@@ -419,7 +658,7 @@ mod tests {
}
#[test]
fn log_is_head_tail_truncated_after_metadata_removal() {
fn log_is_compacted_to_short_hashes_and_subjects() {
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
let ctx = test_ctx(Some("log"), "git log", &cfg);
let mut input = String::new();
@@ -433,10 +672,52 @@ mod tests {
input.push('\n');
}
let out = filter(&ctx, &input, 0);
assert!(out.text.contains("… "));
assert!(out.text.contains("message 0"));
assert!(out.text.contains("message 69"));
assert!(out.text.contains("… 22 commits omitted …"));
assert!(out.text.contains("abcdef1 message 0"));
assert!(!out.text.contains("message 47"));
assert!(out.text.contains("abcdef1 message 69"));
assert!(!out.text.contains("Author:"));
assert!(!out.text.contains("Date:"));
}
#[test]
fn log_supports_subject_on_commit_line() {
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
let ctx = test_ctx(Some("log"), "git log --stat -10", &cfg);
let input = "commit c84fa3c fix: add website URL (rtk-ai.app)\nAuthor: Somebody\nDate: \
today\n\n README.md | 8 ++++++++\n 1 file changed, 8 insertions(+)\n";
let out = filter(&ctx, input, 0);
assert_eq!(out.text, "c84fa3c fix: add website URL (rtk-ai.app)\n");
}
#[test]
fn diff_condenses_unified_patch_to_stat_and_hunk_samples() {
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
let ctx = test_ctx(Some("diff"), "git diff HEAD~1", &cfg);
let input = "diff --git a/index.html b/index.html\nindex 1b7488b..0ebac4f 100644\n--- \
a/index.html\n+++ b/index.html\n@@ -629,7 +629,7 @@\n width: 100%;\n- \
min-width: 800px;\n+ min-width: 1050px;\n@@ -1051,6 +1051,4 @@\n+ /* \
=== Share My Gain === */\n+ .share-gain { background: var(--bg); \
}\n-old\n+new\n";
let out = filter(&ctx, input, 0);
assert!(out.changed);
assert!(out.text.contains("index.html | 6 "), "{}", out.text);
assert!(
out.text
.contains("1 file changed, 4 insertions(+), 2 deletions(-)")
);
assert!(out.text.contains("--- Changes ---"));
assert!(out.text.contains("@@ -629,7 +629,7 @@"));
assert!(out.text.contains("- min-width: 800px;"));
assert!(out.text.contains("+ min-width: 1050px;"));
}
#[test]
fn legacy_log_fallback_removes_metadata_when_no_commit_records_parse() {
let input = "commitish output\nAuthor: Somebody <s@example.com>\nDate: today\nmessage 0\n";
let out = condense_log(input, 32, 16);
assert!(out.contains("message 0"));
assert!(!out.contains("Author:"));
assert!(!out.contains("Date:"));
}
}
@@ -123,6 +123,14 @@ fn should_keep_go_test_line(line: &str, exit_code: i32) -> bool {
let trimmed = line.trim();
let lower = trimmed.to_ascii_lowercase();
if exit_code == 0 {
return trimmed.starts_with("--- PASS")
|| trimmed.starts_with("--- SKIP")
|| lower.starts_with("ok\t")
|| lower.starts_with("ok ")
|| lower.starts_with("?\t");
}
trimmed.starts_with("FAIL")
|| trimmed.starts_with("--- FAIL")
|| trimmed.starts_with("panic:")
@@ -137,6 +145,7 @@ fn should_keep_go_test_line(line: &str, exit_code: i32) -> bool {
|| lower.contains("assert")
|| lower.contains("killed with quit")
|| lower.starts_with("ok\t")
|| lower.starts_with("ok ")
|| lower.starts_with("?\t")
|| exit_code != 0 && (lower.contains("timeout") || lower.contains("signal"))
}
@@ -363,6 +372,27 @@ mod tests {
assert!(out.text.contains("Test killed with quit"));
}
#[test]
fn go_test_verbose_success_drops_run_and_ginkgo_success_noise() {
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
let ctx = MinimizerCtx {
program: "go",
subcommand: Some("test"),
command: "go test ./... -v",
config: &cfg,
};
let input = "=== RUN TestControllers\nRunning Suite: Controller Suite\nSUCCESS! -- 1 \
Passed | 0 Failed | 0 Pending\n--- PASS: TestControllers (6.04s)\nPASS\nok \
kubecraft.ai/.../controller 6.610s\n=== RUN TestNewClient\n--- PASS: \
TestNewClient (0.00s)\nPASS\nok kubecraft.ai/.../llm 0.776s\n";
let out = filter(&ctx, input, 0);
assert!(out.text.contains("--- PASS: TestControllers (6.04s)"));
assert!(out.text.contains("ok kubecraft.ai/.../controller 6.610s"));
assert!(out.text.contains("--- PASS: TestNewClient (0.00s)"));
assert!(!out.text.contains("=== RUN"));
assert!(!out.text.contains("SUCCESS!"));
}
#[test]
fn summarizes_golangci_json_issues() {
let input = r#"{"Issues":[{"FromLinter":"govet","Text":"unreachable code","Pos":{"Filename":"main.go","Line":7,"Column":2}}]}"#;
@@ -242,8 +242,8 @@ mod tests {
let out = filter(&ctx, "## main\n M a.rs\n?? b.rs\n", 0);
assert!(out.changed);
assert!(out.text.contains("git status summary on main"));
assert!(out.text.contains("unstaged: 1"));
assert!(out.text.contains("git status: main"));
assert!(out.text.contains("modified: 1"));
assert!(out.text.contains("untracked: 1"));
}
@@ -1,5 +1,7 @@
//! Filesystem listing and search filters.
use std::{collections::BTreeMap, path::Path};
use crate::shell::minimizer::{MinimizerCtx, MinimizerOutput, primitives};
pub fn filter(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> MinimizerOutput {
@@ -8,9 +10,11 @@ pub fn filter(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> MinimizerO
cleaned
} else {
match ctx.program {
"grep" | "rg" => primitives::group_by_file(&cleaned, 12),
"ls" | "tree" | "find" => compact_listing_output(&cleaned),
"cat" | "read" => cleaned,
"grep" | "rg" => compact_grep_output(&cleaned),
"ls" => compact_ls_output(&cleaned).unwrap_or_else(|| compact_listing_output(&cleaned)),
"tree" => compact_listing_output(&cleaned),
"find" => compact_find_output(&cleaned),
"cat" | "read" => compact_cat_output(ctx, &cleaned),
"stat" | "du" | "df" | "wc" => compact_summary_output(&cleaned),
"jq" | "json" => cleaned,
_ => cleaned,
@@ -27,6 +31,661 @@ fn compact_listing_output(input: &str) -> String {
primitives::compact_listing(input, 80)
}
struct GrepMatch {
line_no: String,
text: String,
}
fn compact_grep_output(input: &str) -> String {
let mut grouped: BTreeMap<String, Vec<GrepMatch>> = BTreeMap::new();
let mut ungrouped = Vec::new();
for line in input.lines() {
if let Some((file, line_no, text)) = split_grep_line(line) {
grouped
.entry(file.to_string())
.or_default()
.push(GrepMatch { line_no: line_no.to_string(), text: collapse_match_text(text) });
} else if !line.trim().is_empty() {
ungrouped.push(line.to_string());
}
}
let match_count: usize = grouped.values().map(Vec::len).sum();
if grouped.is_empty() || match_count <= 12 && grouped.len() <= 3 {
return primitives::group_by_file(input, 12);
}
let mut out = format!("grep: {match_count} matches in {} files\n", grouped.len());
let mut shown_matches = 0usize;
let mut shown_files = 0usize;
for (file, matches) in &grouped {
if shown_files >= 12 {
break;
}
shown_files += 1;
out.push('\n');
out.push_str(file);
out.push_str(":\n");
for entry in matches.iter().take(4) {
shown_matches += 1;
out.push_str(" ");
out.push_str(&entry.line_no);
out.push_str(": ");
out.push_str(&entry.text);
out.push('\n');
}
if matches.len() > 4 {
out.push_str(" … ");
out.push_str(&(matches.len() - 4).to_string());
out.push_str(" more in file\n");
}
}
let omitted_files = grouped.len().saturating_sub(shown_files);
let omitted_matches = match_count.saturating_sub(shown_matches);
if omitted_files > 0 || omitted_matches > 0 {
out.push_str("\n… ");
out.push_str(&omitted_matches.to_string());
out.push_str(" matches");
if omitted_files > 0 {
out.push_str(" in ");
out.push_str(&omitted_files.to_string());
out.push_str(" files");
}
out.push_str(" omitted\n");
}
for line in ungrouped {
out.push_str(&line);
out.push('\n');
}
out
}
fn split_grep_line(line: &str) -> Option<(&str, &str, &str)> {
let (file, rest) = line.split_once(':')?;
if file.is_empty() || file.starts_with(' ') {
return None;
}
let (line_no, text) = rest.split_once(':')?;
if !line_no.chars().all(|ch| ch.is_ascii_digit()) {
return None;
}
Some((file, line_no, text.trim_start()))
}
fn collapse_match_text(text: &str) -> String {
let collapsed = collapse_parenthesized_segment(text, 48);
primitives::truncate_line(&collapsed, 140)
}
fn collapse_parenthesized_segment(text: &str, min_len: usize) -> String {
let Some(open) = text.find('(') else {
return text.to_string();
};
let Some(close_rel) = text[open + 1..].find(')') else {
return text.to_string();
};
let close = open + 1 + close_rel;
if close.saturating_sub(open) < min_len {
return text.to_string();
}
let mut out = String::new();
out.push_str(&text[..=open]);
out.push_str("...");
out.push_str(&text[close..]);
out
}
fn compact_find_output(input: &str) -> String {
let paths: Vec<&str> = input
.lines()
.filter(|line| !line.trim().is_empty())
.collect();
if paths.len() <= 20 {
return input.to_string();
}
let mut grouped: BTreeMap<String, Vec<String>> = BTreeMap::new();
let mut skipped_noise = 0usize;
for raw in &paths {
let normalized = normalize_listing_path(raw);
if normalized.is_empty() {
continue;
}
if path_has_noise_dir(&normalized) {
skipped_noise += 1;
continue;
}
let path = Path::new(&normalized);
let name = path
.file_name()
.and_then(|value| value.to_str())
.map_or_else(|| normalized.clone(), ToString::to_string);
let dir = path
.parent()
.and_then(|value| value.to_str())
.filter(|value| !value.is_empty())
.map_or(".", |value| value);
grouped.entry(dir.to_string()).or_default().push(name);
}
if grouped.is_empty() {
return primitives::compact_listing(input, 80);
}
let mut out = format!("find: {} paths in {} dirs\n", paths.len(), grouped.len());
for (dir, names) in grouped.iter().take(16) {
out.push('\n');
out.push_str(dir);
out.push_str("/ ");
push_wrapped_names(&mut out, names, 4, 24);
}
if grouped.len() > 16 {
out.push_str("\n… ");
out.push_str(&(grouped.len() - 16).to_string());
out.push_str(" dirs omitted\n");
}
if skipped_noise > 0 {
out.push_str("… ");
out.push_str(&skipped_noise.to_string());
out.push_str(" noisy paths omitted\n");
}
out
}
fn normalize_listing_path(raw: &str) -> String {
raw.trim()
.trim_start_matches("./")
.trim_end_matches('/')
.to_string()
}
fn path_has_noise_dir(path: &str) -> bool {
path.split('/').any(|part| {
matches!(
part,
".git" | "node_modules" | "target" | "dist" | "build" | ".next" | ".turbo" | ".cache"
)
})
}
fn push_wrapped_names(out: &mut String, names: &[String], per_line: usize, max_names: usize) {
let shown = names.len().min(max_names);
for (idx, name) in names.iter().take(shown).enumerate() {
if idx > 0 {
if idx % per_line == 0 {
out.push_str("\n ");
} else {
out.push(' ');
}
}
out.push_str(name);
}
out.push('\n');
if names.len() > max_names {
out.push_str(" … ");
out.push_str(&(names.len() - max_names).to_string());
out.push_str(" more\n");
}
}
struct LsEntry {
name: String,
is_dir: bool,
size: Option<u64>,
is_file: bool,
}
fn compact_ls_output(input: &str) -> Option<String> {
let entries: Vec<LsEntry> = input.lines().filter_map(parse_ls_long_line).collect();
if entries.len() <= 20 {
return None;
}
let dir_count = entries.iter().filter(|entry| entry.is_dir).count();
let file_count = entries.iter().filter(|entry| entry.is_file).count();
let mut ext_counts: BTreeMap<String, usize> = BTreeMap::new();
for entry in entries.iter().filter(|entry| entry.is_file) {
if let Some(ext) = Path::new(&entry.name)
.extension()
.and_then(|value| value.to_str())
{
*ext_counts.entry(ext.to_string()).or_default() += 1;
}
}
let mut out = String::new();
for entry in entries.iter().filter(|entry| entry.is_dir).take(12) {
out.push_str(&entry.name);
out.push_str("/\n");
}
for entry in entries.iter().filter(|entry| entry.is_file).take(36) {
out.push_str(&entry.name);
if let Some(size) = entry.size {
out.push_str(" ");
out.push_str(&format_human_size(size));
}
out.push('\n');
}
let shown = dir_count.min(12) + file_count.min(36);
if entries.len() > shown {
out.push_str("… ");
out.push_str(&(entries.len() - shown).to_string());
out.push_str(" entries omitted\n");
}
out.push('\n');
out.push_str(&file_count.to_string());
out.push_str(" files, ");
out.push_str(&dir_count.to_string());
out.push_str(" dirs");
if !ext_counts.is_empty() {
let ext_summary = ext_counts
.iter()
.take(4)
.map(|(ext, count)| format!("{count} .{ext}"))
.collect::<Vec<_>>()
.join(", ");
out.push_str(" (");
out.push_str(&ext_summary);
out.push(')');
}
out.push('\n');
Some(out)
}
fn parse_ls_long_line(line: &str) -> Option<LsEntry> {
let trimmed = line.trim();
if trimmed.starts_with("total ") || trimmed.is_empty() {
return None;
}
let kind = trimmed.chars().next()?;
if !matches!(kind, 'd' | '-' | 'l') {
return None;
}
let parts: Vec<&str> = trimmed.split_whitespace().collect();
if parts.len() < 9 {
return None;
}
let name = parts[8..].join(" ");
if matches!(name.as_str(), "." | "..") {
return None;
}
Some(LsEntry {
name,
is_dir: kind == 'd',
size: parts.get(4).and_then(|value| value.parse().ok()),
is_file: kind == '-',
})
}
fn format_human_size(size: u64) -> String {
const KIB: f64 = 1024.0;
const MIB: f64 = 1024.0 * 1024.0;
const GIB: f64 = 1024.0 * 1024.0 * 1024.0;
let value = size as f64;
if value >= GIB {
format!("{:.1}G", value / GIB)
} else if value >= MIB {
format!("{:.1}M", value / MIB)
} else if value >= KIB {
format!("{:.1}K", value / KIB)
} else {
format!("{size}B")
}
}
fn compact_cat_output(ctx: &MinimizerCtx<'_>, input: &str) -> String {
let Some(path) = extract_single_path_arg(ctx.command, ctx.program) else {
return input.to_string();
};
if let Some(summary) = summarize_manifest(&path, input) {
return summary;
}
if !is_source_path(&path) {
return input.to_string();
}
compact_source_outline(input)
}
fn extract_single_path_arg(command: &str, program: &str) -> Option<String> {
let mut saw_program = false;
for raw in command.split_whitespace() {
let token = raw.trim_matches(|ch| ch == '\'' || ch == '"');
let normalized = token.rsplit('/').next().unwrap_or(token);
if !saw_program {
if normalized == program {
saw_program = true;
}
continue;
}
if token.starts_with('-') {
continue;
}
return Some(token.to_string());
}
None
}
fn summarize_manifest(path: &str, input: &str) -> Option<String> {
let name = Path::new(path).file_name()?.to_str()?;
match name {
"Cargo.toml" => summarize_cargo_toml(input),
"package.json" => summarize_package_json(input),
"go.mod" => summarize_go_mod(input),
_ => None,
}
}
fn summarize_cargo_toml(input: &str) -> Option<String> {
let mut package_name = None;
let mut dependencies = Vec::new();
let mut section = "";
for line in input.lines() {
let trimmed = line.trim();
if trimmed.starts_with('[') && trimmed.ends_with(']') {
section = trimmed.trim_matches(&['[', ']'][..]);
continue;
}
if section == "package" && trimmed.starts_with("name") && package_name.is_none() {
package_name = parse_toml_string_value(trimmed);
continue;
}
if matches!(section, "dependencies" | "dev-dependencies" | "build-dependencies")
&& let Some(dep) = parse_toml_dependency_line(trimmed)
{
dependencies.push(dep);
}
}
if dependencies.is_empty() {
return None;
}
let mut out = String::from("Cargo.toml");
if let Some(name) = package_name {
out.push_str(": ");
out.push_str(&name);
}
out.push('\n');
out.push_str("dependencies: ");
out.push_str(&dependencies.len().to_string());
out.push('\n');
for dep in dependencies.iter().take(15) {
out.push_str(" ");
out.push_str(dep);
out.push('\n');
}
if dependencies.len() > 15 {
out.push_str(" … ");
out.push_str(&(dependencies.len() - 15).to_string());
out.push_str(" more\n");
}
Some(out)
}
fn parse_toml_string_value(line: &str) -> Option<String> {
let (_, value) = line.split_once('=')?;
let value = value.trim();
Some(value.trim_matches('"').to_string())
}
fn parse_toml_dependency_line(line: &str) -> Option<String> {
if line.is_empty() || line.starts_with('#') {
return None;
}
let (name, value) = line.split_once('=')?;
let name = name.trim();
if name.is_empty() {
return None;
}
let version = parse_dependency_version(value.trim());
Some(match version {
Some(version) => format!("{name} {version}"),
None => name.to_string(),
})
}
fn parse_dependency_version(value: &str) -> Option<String> {
if value.starts_with('"') {
return Some(value.trim_matches('"').to_string());
}
if let Some(start) = value.find("version") {
let after = value[start..].split_once('=')?.1.trim();
if let Some(rest) = after.strip_prefix('"')
&& let Some(end) = rest.find('"')
{
return Some(rest[..end].to_string());
}
return after
.split_whitespace()
.next()
.map(|version| version.trim_matches(&[',', '}'][..]).to_string());
}
None
}
fn summarize_package_json(input: &str) -> Option<String> {
let value: serde_json::Value = serde_json::from_str(input).ok()?;
let name = value.get("name").and_then(|value| value.as_str());
let mut deps = Vec::new();
for section in ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"] {
let Some(object) = value.get(section).and_then(|value| value.as_object()) else {
continue;
};
for (dep, version) in object {
let version = version.as_str().unwrap_or("");
deps.push(if version.is_empty() {
dep.clone()
} else {
format!("{dep} {version}")
});
}
}
if deps.is_empty() {
return None;
}
let mut out = String::from("package.json");
if let Some(name) = name {
out.push_str(": ");
out.push_str(name);
}
out.push('\n');
out.push_str("dependencies: ");
out.push_str(&deps.len().to_string());
out.push('\n');
for dep in deps.iter().take(15) {
out.push_str(" ");
out.push_str(dep);
out.push('\n');
}
if deps.len() > 15 {
out.push_str(" … ");
out.push_str(&(deps.len() - 15).to_string());
out.push_str(" more\n");
}
Some(out)
}
fn summarize_go_mod(input: &str) -> Option<String> {
let mut module = None;
let mut deps = Vec::new();
let mut in_require_block = false;
for line in input.lines() {
let trimmed = line.trim();
if let Some(value) = trimmed.strip_prefix("module ") {
module = Some(value.trim().to_string());
continue;
}
if trimmed == "require (" {
in_require_block = true;
continue;
}
if in_require_block && trimmed == ")" {
in_require_block = false;
continue;
}
if let Some(dep) = parse_go_require_line(trimmed, in_require_block) {
deps.push(dep);
}
}
if deps.is_empty() {
return None;
}
let mut out = String::from("go.mod");
if let Some(module) = module {
out.push_str(": ");
out.push_str(&module);
}
out.push('\n');
out.push_str("dependencies: ");
out.push_str(&deps.len().to_string());
out.push('\n');
for dep in deps.iter().take(15) {
out.push_str(" ");
out.push_str(dep);
out.push('\n');
}
if deps.len() > 15 {
out.push_str(" … ");
out.push_str(&(deps.len() - 15).to_string());
out.push_str(" more\n");
}
Some(out)
}
fn parse_go_require_line(line: &str, in_block: bool) -> Option<String> {
let rest = if in_block {
line
} else {
line.strip_prefix("require ")?
};
let mut parts = rest.split_whitespace();
let name = parts.next()?;
let version = parts.next().unwrap_or("");
if name.is_empty() || name.starts_with("//") {
return None;
}
Some(if version.is_empty() {
name.to_string()
} else {
format!("{name} {version}")
})
}
fn is_source_path(path: &str) -> bool {
let Some(ext) = Path::new(path).extension().and_then(|value| value.to_str()) else {
return false;
};
matches!(
ext,
"rs"
| "ts" | "tsx"
| "js" | "jsx"
| "py" | "go"
| "java"
| "c" | "cc"
| "cpp"
| "h" | "hpp"
| "swift"
| "kt" | "rb"
)
}
fn compact_source_outline(input: &str) -> String {
let lines: Vec<&str> = input.lines().collect();
if lines.len() < 160 && input.len() < 12_000 {
return input.to_string();
}
let mut out = String::new();
let mut emitted = 0usize;
for line in &lines {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with("//") {
continue;
}
if is_source_import_or_module(trimmed) {
out.push_str(&primitives::truncate_line(trimmed, 140));
out.push('\n');
emitted += 1;
if emitted >= 40 {
break;
}
}
}
if has_content(&out) {
out.push('\n');
}
let mut declarations = 0usize;
for line in &lines {
if declarations >= 80 {
break;
}
if line.chars().next().is_some_and(char::is_whitespace) {
continue;
}
let trimmed = line.trim();
if !is_source_declaration(trimmed) {
continue;
}
out.push_str(&render_source_declaration(trimmed));
out.push('\n');
declarations += 1;
}
if declarations == 0 {
return primitives::head_tail_lines(input, 60, 30);
}
if lines.len() > emitted + declarations {
out.push_str("… ");
out.push_str(&lines.len().to_string());
out.push_str(" lines summarized\n");
}
out
}
fn is_source_import_or_module(trimmed: &str) -> bool {
trimmed.starts_with("use ")
|| trimmed.starts_with("mod ")
|| trimmed.starts_with("import ")
|| trimmed.starts_with("from ")
|| trimmed.starts_with("package ")
}
fn is_source_declaration(trimmed: &str) -> bool {
let without_vis = trimmed
.strip_prefix("pub ")
.or_else(|| trimmed.strip_prefix("export "))
.or_else(|| trimmed.strip_prefix("async "))
.unwrap_or(trimmed);
without_vis.starts_with("fn ")
|| without_vis.starts_with("struct ")
|| without_vis.starts_with("enum ")
|| without_vis.starts_with("trait ")
|| without_vis.starts_with("impl ")
|| without_vis.starts_with("type ")
|| without_vis.starts_with("class ")
|| without_vis.starts_with("interface ")
|| without_vis.starts_with("function ")
|| without_vis.starts_with("def ")
}
fn render_source_declaration(trimmed: &str) -> String {
let line = primitives::truncate_line(trimmed, 160);
if let Some(before) = line.strip_suffix('{') {
let mut out = before.trim_end().to_string();
out.push_str(" { ... }");
return out;
}
if let Some(index) = line.find('{') {
let mut out = line[..index].trim_end().to_string();
out.push_str(" { ... }");
return out;
}
line
}
fn compact_summary_output(input: &str) -> String {
let lines: Vec<&str> = input.lines().collect();
if lines.len() <= 30 {
@@ -58,6 +717,10 @@ fn is_summary_line(line: &str) -> bool {
|| lower.contains(" files ")
}
fn has_content(text: &str) -> bool {
text.lines().any(|line| !line.trim().is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -67,6 +730,14 @@ mod tests {
MinimizerCtx { program, subcommand: None, command: program, config: cfg }
}
fn ctx_command<'a>(
program: &'a str,
command: &'a str,
cfg: &'a MinimizerConfig,
) -> MinimizerCtx<'a> {
MinimizerCtx { program, subcommand: None, command, config: cfg }
}
#[test]
fn groups_grep_by_file() {
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
@@ -75,6 +746,24 @@ mod tests {
assert_eq!(out.text, "a.rs:\n 1:foo\n 2:bar\n");
}
#[test]
fn compacts_large_grep_output_with_summary() {
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
let ctx = ctx("grep", &cfg);
let mut input = String::new();
for idx in 0..20 {
input.push_str("src/file");
input.push_str(&idx.to_string());
input.push_str(
".rs:17:pub fn run(cmd: CargoCommand, args: &[String], verbose: u8) -> Result<()> {\n",
);
}
let out = filter(&ctx, &input, 0);
assert!(out.text.starts_with("grep: 20 matches in 20 files"));
assert!(out.text.contains("17: pub fn run(...) -> Result<()> {"));
assert!(out.text.contains("matches in 8 files omitted"));
}
#[test]
fn preserves_long_cat_output() {
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
@@ -84,6 +773,39 @@ mod tests {
assert_eq!(out.text, input);
}
#[test]
fn summarizes_cargo_manifest_from_cat() {
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
let ctx = ctx_command("cat", "cat Cargo.toml", &cfg);
let input = "[package]\nname = \"rtk\"\n\n[dependencies]\nclap = { version = \"4\", \
features = [\"derive\"] }\nanyhow = \"1.0\"\nserde_json = \"1\"\n";
let out = filter(&ctx, input, 0);
assert_eq!(
out.text,
"Cargo.toml: rtk\ndependencies: 3\n clap 4\n anyhow 1.0\n serde_json 1\n"
);
}
#[test]
fn outlines_large_source_cat() {
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
let ctx = ctx_command("cat", "cat src/main.rs", &cfg);
let mut input = String::from("use anyhow::Result;\nmod cargo_cmd;\n\nstruct Cli {\n");
for idx in 0..180 {
input.push_str(" field_");
input.push_str(&idx.to_string());
input.push_str(": String,\n");
}
input.push_str("}\nfn main() -> Result<()> {\n Ok(())\n}\n");
let out = filter(&ctx, &input, 0);
assert!(out.text.contains("use anyhow::Result;"));
assert!(out.text.contains("mod cargo_cmd;"));
assert!(out.text.contains("struct Cli { ... }"));
assert!(out.text.contains("fn main() -> Result<()> { ... }"));
assert!(out.text.contains("lines summarized"));
assert!(!out.text.contains("field_179"));
}
#[test]
fn preserves_short_read_output() {
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
@@ -93,6 +815,45 @@ mod tests {
assert_eq!(out.text, input);
}
#[test]
fn compacts_find_paths_by_directory_and_skips_noise_dirs() {
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
let ctx = ctx("find", &cfg);
let mut input = String::from("./target/debug/build/out/private.rs\n");
for idx in 0..30 {
input.push_str("./src/module");
input.push_str(&(idx / 10).to_string());
input.push_str("/file");
input.push_str(&idx.to_string());
input.push_str(".rs\n");
}
let out = filter(&ctx, &input, 0);
assert!(out.text.starts_with("find: 31 paths in 3 dirs"));
assert!(out.text.contains("src/module0/ file0.rs file1.rs"));
assert!(out.text.contains("1 noisy paths omitted"));
assert!(!out.text.contains("target/debug"));
}
#[test]
fn compacts_long_ls_listing() {
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
let ctx = ctx("ls", &cfg);
let mut input = String::from("total 928\n");
input.push_str("drwxr-xr-x 6 user staff 192 2 feb 21:35 discover\n");
input.push_str("drwxr-xr-x 5 user staff 160 2 feb 21:35 parser\n");
for idx in 0..25 {
input.push_str("-rw-r--r-- 1 user staff ");
input.push_str(&(1024 * (idx + 1)).to_string());
input.push_str(" 2 feb 21:35 file");
input.push_str(&idx.to_string());
input.push_str(".rs\n");
}
let out = filter(&ctx, &input, 0);
assert!(out.text.contains("discover/"));
assert!(out.text.contains("file0.rs 1.0K"));
assert!(out.text.contains("25 files, 2 dirs (25 .rs)"));
}
#[test]
fn compacts_df_output_without_losing_filesystem_header() {
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
@@ -171,6 +171,7 @@ fn is_pytest_section_delimiter(trimmed: &str) -> bool {
fn is_pytest_pass_noise(trimmed: &str) -> bool {
trimmed.is_empty()
|| trimmed.contains("test session starts")
|| trimmed.starts_with("collecting ")
|| trimmed.starts_with("collected ")
|| trimmed.starts_with("rootdir:")
@@ -178,11 +179,20 @@ fn is_pytest_pass_noise(trimmed: &str) -> bool {
|| trimmed.starts_with("plugins:")
|| trimmed.starts_with("platform ")
|| trimmed.starts_with("cachedir:")
|| is_pytest_verbose_pass_line(trimmed)
|| trimmed
.chars()
.all(|ch| matches!(ch, '.' | 's' | 'S' | 'x' | 'X' | 'f' | 'F' | 'E'))
}
fn is_pytest_verbose_pass_line(trimmed: &str) -> bool {
if !trimmed.contains("::") {
return false;
}
let mut parts = trimmed.split_whitespace();
parts.any(|part| matches!(part, "PASSED" | "SKIPPED" | "XPASS" | "XFAIL"))
}
fn is_ruff_format(ctx: &MinimizerCtx<'_>) -> bool {
ctx.subcommand == Some("format") || ctx.command.split_whitespace().any(|part| part == "format")
}
@@ -291,6 +301,17 @@ mod tests {
assert!(out.contains("5 failed, 1698 passed, 2 skipped in 108.89s"));
}
#[test]
fn pytest_verbose_success_collapses_to_summary() {
let input = "===== test session starts ======\nplatform darwin -- Python 3.14.3, \
pytest-9.0.2\ncachedir: .pytest_cache\nrootdir: /app\nplugins: \
anyio-4.12.1\ncollected 33 items\n\ntest_utils.py::TestStringUtils::test_strip \
PASSED [ 3%]\ntest_utils.py::TestListOps::test_flatten PASSED \
[100%]\n\n====== 33 passed in 0.05s ======\n";
let out = filter_pytest(input, 0);
assert_eq!(out, "====== 33 passed in 0.05s ======\n");
}
#[test]
fn ruff_format_preserves_changed_files_and_summaries() {
let cfg = MinimizerConfig { enabled: true, ..Default::default() };
+1 -1
View File
@@ -1,7 +1,6 @@
# Changelog
## [Unreleased]
### Added
- Added full-output retrieval metadata to minimized shell command output by appending an `artifact://<id>` footer with byte counts, allowing users to open the original unminimized command output
@@ -16,6 +15,7 @@
### Fixed
- Fixed shell execution output to replace raw streamed bash output with the minimizer’s rewritten text before final output while still preserving the full original output as artifact metadata
- Fixed bash command minimization to save the full unminimized output as a `bash-original` artifact during AgentSession shell execution, enabling `artifact://` access to complete command output
- Fixed minimized shell output for compound bash commands such as `cmd && next` and `cmd ; next`, preserving the original unminimized output through `artifact://<id>` without filtering piped command output
- Fixed streaming chunk previews that could display an incomplete trailing edit as a deletion when partial JSON temporarily converted in-flight values to `null`
+17 -13
View File
@@ -234,20 +234,24 @@ export async function executeBash(command: string, options?: BashExecutorOptions
};
}
// When the native minimizer rewrote the output, persist the original
// as a session artifact and splice an `artifact://<id>` footer into the
// visible text so the agent can retrieve the raw bytes losslessly.
// When the native minimizer rewrote the output, swap the sink's accumulated
// raw stream for the minimized text, persist the original as a session
// artifact, and splice an `artifact://<id>` footer into the visible text so
// the agent can retrieve the raw bytes losslessly.
const minimized = winner.result.minimized;
if (minimized && options?.onMinimizedSave) {
const artifactId = await options.onMinimizedSave(minimized.originalText, {
filter: minimized.filter,
inputBytes: minimized.inputBytes,
outputBytes: minimized.outputBytes,
});
if (artifactId) {
sink.push(
`\n… full output: artifact://${artifactId} (${minimized.inputBytes} → ${minimized.outputBytes} bytes)\n`,
);
if (minimized) {
sink.replace(minimized.text);
if (options?.onMinimizedSave) {
const artifactId = await options.onMinimizedSave(minimized.originalText, {
filter: minimized.filter,
inputBytes: minimized.inputBytes,
outputBytes: minimized.outputBytes,
});
if (artifactId) {
sink.push(
`\n… full output: artifact://${artifactId} (${minimized.inputBytes} → ${minimized.outputBytes} bytes)\n`,
);
}
}
}
@@ -680,6 +680,17 @@ export class OutputSink {
});
}
/**
* Replace the in-memory buffer with the given text while preserving the
* streaming counters (totalLines/totalBytes reflect the raw chunks that
* already reached the sink). Used when an upstream minimizer rewrites the
* captured output after the raw bytes have already been streamed.
*/
replace(text: string): void {
this.#buffer = text;
this.#bufferBytes = Buffer.byteLength(text, "utf-8");
}
async dump(notice?: string): Promise<OutputSummary> {
const noticeLine = notice ? `[${notice}]\n` : "";
const outputLines = this.#buffer.length > 0 ? countNewlines(this.#buffer) + 1 : 0;
+7 -3
View File
@@ -1,22 +1,26 @@
# Changelog
## [Unreleased]
### Added
- Added `text` to `MinimizerResult` so consumers can replace rewritten output with the minimized replacement text
- Added `settingsHash` to `MinimizerOptions` to verify the minimizer `settingsPath` contents against a xxHash64 digest before applying them
- Added `minimized` output telemetry via `MinimizerResult` on `ShellExecuteResult` and `ShellRunResult`, exposing the applied minimizer filter and original/minimized byte counts when output is rewritten
- Added a new `minimizer` option to `ShellExecuteOptions` and `ShellOptions` to configure per-command output minimization
- Added the `MinimizerOptions` API with controls for enabling minimization, overriding settings via `settingsPath`, allow/deny lists (`only`, `except`), and `maxCaptureBytes` capture limits
### Security
### Changed
- Added trust-gated loading for minimizer settings by requiring a matching `settingsHash` before accepting a settings file
- Changed the shell output minimizer to more aggressively compact successful test runs, git output, large listings, grep/find results, source reads, and dependency manifests
### Removed
- Removed `PI_DEV` loader diagnostic env var and associated console logging in the native addon loader
### Security
- Added trust-gated loading for minimizer settings by requiring a matching `settingsHash` before accepting a settings file
## [14.2.0] - 2026-04-23
### Added
+13 -5
View File
@@ -1037,11 +1037,13 @@ export interface MinimizerOptions {
}
/**
* Telemetry for a single minimization, surfaced when the minimizer
* actually rewrote the command's output. The session layer is expected to
* persist `original_text` via its `ArtifactManager` and splice the
* resulting `artifact://<id>` reference into whatever is shown to the
* agent.
* Telemetry for a single minimization.
*
* Surfaced when the minimizer actually rewrote the command's output. The
* session layer is expected to persist `original_text` via its
* `ArtifactManager`, splice the resulting `artifact://<id>` reference
* into `text`, and replace any previously streamed raw output with the
* minimized text.
*/
export interface MinimizerResult {
/**
@@ -1049,6 +1051,12 @@ export interface MinimizerResult {
* `"pipeline:gradle"`, `"pipeline+builtin"`).
*/
filter: string
/**
* The minimized replacement text. Callers that streamed raw chunks
* during execution should clear and replace their accumulated output
* with this text.
*/
text: string
/** The full original capture, before minimization. */
originalText: string
/** Captured byte length before minimization. */