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:
@@ -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,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`
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+13
-5
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user