feat: TOML Part 2 — user-global config, shadow warning, rtk init templates, 4 new built-in filters (#351)

* docs: update module count to 58 (toml_filter + verify_cmd)

* docs: bump version refs to 0.27.1, fix module count to 59

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: TOML DSL PR 2 — user-global config, shadow warning, init templates, 4 new filters

- toml_filter: add ~/.config/rtk/filters.toml (priority 2, between project and built-in)
- toml_filter: shadow warning when match_command overlaps a Rust-handled command
- init: rtk init generates .rtk/filters.toml template (local) and ~/.config/rtk/filters.toml (global)
- builtin_filters: add pre-commit, helm, gcloud, ansible-playbook (46/46 inline tests)
- README: add "Custom Filters" section with lookup table, primitives, examples, built-in list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(filters): add quarto-render, pnpm-build, trunk-build from rtk discover data (52/52 tests)

* feat(filters): add docker-inspect, sops, docker-compose-ps built-in filters

Resolves issues #279, #277, #276 as TOML-native filters (no Rust required).

- docker-inspect: strip_ansi + truncate_lines_at=120 + max_lines=60
- sops: strip_ansi + strip_lines_matching blank lines + max_lines=40
- docker-compose-ps: strip_ansi + truncate_lines_at=120 + max_lines=40

6 inline tests added (2 per filter), all passing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(filters): split monolithic builtin_filters.toml into 28 individual files

Replace the 848-line src/builtin_filters.toml monolith with individual
files under src/filters/<name>.toml (1 filter + tests per file).

Files are concatenated alphabetically by build.rs at compile time into
OUT_DIR/builtin_filters.toml, then embedded via a BUILTIN_TOML constant
in toml_filter.rs. The build step validates TOML syntax and detects
duplicate filter names across files.

Benefits:
- Zero merge conflicts when multiple PRs add filters (different files)
- Clear PR diffs: "+1 file of 30 lines" vs "+30 lines in 850-line file"
- Easy onboarding: copy any .toml, rename, edit 3 fields — done
- Build-time TOML validation catches syntax errors before tests run

Changes:
- build.rs (new): concat + validate src/filters/*.toml
- Cargo.toml: add [build-dependencies] toml = "0.8"
- src/filters/*.toml (28 files): split from monolith
- src/filters/README.md (new): contributor guide
- src/toml_filter.rs: BUILTIN_TOML const + 5 include_str replacements
- src/builtin_filters.toml: deleted

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(filters): add 5 new unit tests for multi-file architecture + wget filter

Unit tests added (toml_filter.rs):
- test_builtin_toml_has_schema_version: ensures build.rs injects schema_version
- test_builtin_all_expected_filters_present: guards against accidental file deletion
- test_builtin_filter_count: exact count check (fails if filter added/removed without update)
- test_builtin_all_filters_have_inline_tests: prevents shipping filters with no tests
- test_new_filter_discoverable_after_concat: simulates build.rs concat, verifies lookup

New built-in filter:
- src/filters/wget.toml: compact wget download output (strips connection/resolution
  noise, short-circuits on 'saved [' to 'ok (downloaded)', 2 inline tests)

Test results: 661/661 unit tests, 60/60 inline TOML filter tests

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs: add filter-workflow.md with Mermaid diagrams

Build pipeline + runtime lookup priority, both as Mermaid flowcharts.
Shows the full path from src/filters/*.toml to binary to execution.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: remove dead TOML filters — wget, git-checkout, git-merge, git-remote, cargo-run

These filters were never reachable: Clap routes rtk git/cargo commands
to their dedicated Rust modules before run_fallback is called. The TOML
engine only fires for unknown commands. Shipping dead filters gives false
confidence (inline tests pass, but filters never activate in production).

Also fixes P1-1 from the PR #386 review: wget was in RUST_HANDLED_COMMANDS
and the shadow warning fired at compile time but did not block the build.

Removed: cargo-run.toml, git-checkout.toml, git-merge.toml,
         git-remote.toml, wget.toml

Updated test guards: count 29→24, expected list -5 names,
concat test 30→25.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: remove 3 dead TOML filters + update guards (PR #351)

docker-inspect, docker-compose-ps, and pnpm-build are handled by
container.rs and pnpm_cmd.rs before run_fallback is reached — their
TOML filters never fire. Remove the files to avoid false documentation.

Update toml_filter.rs guards: expected list -3 names, count 24→21,
concat test 25→22. Update CONTRIBUTING.md example count to match.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: rebase on PR #349 — apply regex anchors, make on_empty, build.rs clippy, docs macOS path

- Add (\\s|$) word-boundary anchors to 6 tofu/mix match_command regexes
  (tofu-plan, tofu-init, tofu-validate, tofu-fmt, mix-format, mix-compile)
- Add on_empty = "make: ok" to make.toml + update existing empty-output test
- Fix build.rs clippy: map_or(false, ...) → is_some_and(..)
- Add macOS alt path note to filter-workflow.md Mermaid diagram

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Florian BRUNIAUX
2026-03-10 11:02:18 +01:00
committed by GitHub
parent adda2537be
commit 926e6a0dd4
30 changed files with 1150 additions and 478 deletions
+4 -2
View File
@@ -58,10 +58,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* **toml-dsl:** declarative TOML filter engine — add command filters without writing Rust ([#299](https://github.com/rtk-ai/rtk/issues/299))
* 8 primitives: `strip_ansi`, `replace`, `match_output`, `strip/keep_lines_matching`, `truncate_lines_at`, `head/tail_lines`, `max_lines`, `on_empty`
* lookup chain: `.rtk/filters.toml` (project-local) → built-in filters
* lookup chain: `.rtk/filters.toml` (project-local) → `~/.config/rtk/filters.toml` (user-global) → built-in filters
* `RTK_NO_TOML=1` bypass, `RTK_TOML_DEBUG=1` debug mode
* shadow warning when a TOML filter's match_command overlaps a Rust-handled command
* `rtk init` generates commented filter templates at both project and global level
* `rtk verify` command with `--require-all` for inline test validation
* 14 new built-in filters: `tofu-plan/init/validate/fmt` ([#240](https://github.com/rtk-ai/rtk/issues/240)), `du` ([#284](https://github.com/rtk-ai/rtk/issues/284)), `fail2ban-client` ([#281](https://github.com/rtk-ai/rtk/issues/281)), `iptables` ([#282](https://github.com/rtk-ai/rtk/issues/282)), `mix-format/compile` ([#310](https://github.com/rtk-ai/rtk/issues/310)), `shopify-theme` ([#280](https://github.com/rtk-ai/rtk/issues/280)), `pio-run` ([#231](https://github.com/rtk-ai/rtk/issues/231)), `mvn-build` ([#338](https://github.com/rtk-ai/rtk/issues/338))
* 18 built-in filters: `tofu-plan/init/validate/fmt` ([#240](https://github.com/rtk-ai/rtk/issues/240)), `du` ([#284](https://github.com/rtk-ai/rtk/issues/284)), `fail2ban-client` ([#281](https://github.com/rtk-ai/rtk/issues/281)), `iptables` ([#282](https://github.com/rtk-ai/rtk/issues/282)), `mix-format/compile` ([#310](https://github.com/rtk-ai/rtk/issues/310)), `shopify-theme` ([#280](https://github.com/rtk-ai/rtk/issues/280)), `pio-run` ([#231](https://github.com/rtk-ai/rtk/issues/231)), `mvn-build` ([#338](https://github.com/rtk-ai/rtk/issues/338)), `pre-commit`, `helm`, `gcloud`, `ansible-playbook`
* **hooks:** `exclude_commands` config — exclude specific commands from auto-rewrite ([#243](https://github.com/rtk-ai/rtk/issues/243))
### Bug Fixes
+3
View File
@@ -31,6 +31,9 @@ sha2 = "0.10"
ureq = "2"
hostname = "0.4"
[build-dependencies]
toml = "0.8"
[dev-dependencies]
[profile.release]
+3 -10
View File
@@ -10,7 +10,7 @@
<a href="https://github.com/rtk-ai/rtk/actions"><img src="https://github.com/rtk-ai/rtk/workflows/Security%20Check/badge.svg" alt="CI"></a>
<a href="https://github.com/rtk-ai/rtk/releases"><img src="https://img.shields.io/github/v/release/rtk-ai/rtk" alt="Release"></a>
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT"></a>
<a href="https://discord.gg/gFwRPEKq4p"><img src="https://img.shields.io/discord/1478373640461488159?label=Discord&logo=discord" alt="Discord"></a>
<a href="https://discord.gg/pvHdzAec"><img src="https://img.shields.io/discord/1470188214710046894?label=Discord&logo=discord" alt="Discord"></a>
<a href="https://formulae.brew.sh/formula/rtk"><img src="https://img.shields.io/homebrew/v/rtk" alt="Homebrew"></a>
</p>
@@ -19,7 +19,7 @@
<a href="#installation">Install</a> &bull;
<a href="docs/TROUBLESHOOTING.md">Troubleshooting</a> &bull;
<a href="ARCHITECTURE.md">Architecture</a> &bull;
<a href="https://discord.gg/gFwRPEKq4p">Discord</a>
<a href="https://discord.gg/pvHdzAec">Discord</a>
</p>
<p align="center">
@@ -351,19 +351,12 @@ brew uninstall rtk # If installed via Homebrew
- **[SECURITY.md](SECURITY.md)** - Security policy and PR review process
- **[AUDIT_GUIDE.md](docs/AUDIT_GUIDE.md)** - Token savings analytics guide
## Contributing
Contributions welcome! See the **[Contributing Guide](CONTRIBUTING.md)** for branch naming, PR process, testing requirements, and coding practices.
Contributions welcome! Please open an issue or PR on [GitHub](https://github.com/rtk-ai/rtk).
Join the community on [Discord](https://discord.gg/pvHdzAec).
## License
MIT License - see [LICENSE](LICENSE) for details.
## Contact
- Website: https://www.rtk-ai.app
- Email: contact@rtk-ai.app
- Issues: https://github.com/rtk-ai/rtk/issues
+57
View File
@@ -0,0 +1,57 @@
use std::collections::HashSet;
use std::fs;
use std::path::Path;
fn main() {
let filters_dir = Path::new("src/filters");
let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR must be set by Cargo");
let dest = Path::new(&out_dir).join("builtin_filters.toml");
// Rebuild when any file in src/filters/ changes
println!("cargo:rerun-if-changed=src/filters");
let mut files: Vec<_> = fs::read_dir(filters_dir)
.expect("src/filters/ directory must exist")
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().is_some_and(|ext| ext == "toml"))
.collect();
// Sort alphabetically for deterministic filter ordering
files.sort_by_key(|e| e.file_name());
let mut combined = String::from("schema_version = 1\n\n");
for entry in &files {
let content = fs::read_to_string(entry.path())
.unwrap_or_else(|e| panic!("Failed to read {:?}: {}", entry.path(), e));
combined.push_str(&format!(
"# --- {} ---\n",
entry.file_name().to_string_lossy()
));
combined.push_str(&content);
combined.push_str("\n\n");
}
// Validate: parse the combined TOML to catch errors at build time
let parsed: toml::Value = combined.parse().unwrap_or_else(|e| {
panic!(
"TOML validation failed for combined filters:\n{}\n\nCheck src/filters/*.toml files",
e
)
});
// Detect duplicate filter names across files
if let Some(filters) = parsed.get("filters").and_then(|f| f.as_table()) {
let mut seen: HashSet<String> = HashSet::new();
for key in filters.keys() {
if !seen.insert(key.clone()) {
panic!(
"Duplicate filter name '{}' found across src/filters/*.toml files",
key
);
}
}
}
fs::write(&dest, combined).expect("Failed to write combined builtin_filters.toml");
}
+102
View File
@@ -0,0 +1,102 @@
# How a TOML filter goes from file to execution
This document explains what happens between "I created `src/filters/my-tool.toml`" and "RTK filters the output of `my-tool`".
## Build pipeline
```mermaid
flowchart TD
A[["📄 src/filters/my-tool.toml\n(new file)"]] --> B
subgraph BUILD ["🔨 cargo build"]
B["build.rs\n① ls src/filters/*.toml\n② sort alphabetically\n③ concat → schema_version = 1 + all files"] --> C
C{"TOML valid?\nDuplicate names?"} -->|"❌ panic! (build fails)"| D[["🛑 Error message\npoints to bad file"]]
C -->|"✅ ok"| E[["OUT_DIR/builtin_filters.toml\n(generated file)"]]
E --> F["rustc\ninclude_str!(concat!(env!(OUT_DIR),\n'/builtin_filters.toml'))"]
F --> G[["🦀 rtk binary\nBUILTIN_TOML embedded"]]
end
subgraph TESTS ["🧪 cargo test"]
H["test_builtin_filter_count\nassert_eq!(filters.len(), N)"] -->|"❌ count wrong"| I[["FAIL\n'Expected N, got N+1'\nUpdate the count'"]]
J["test_builtin_all_expected_\nfilters_present\nassert!(names.contains('my-tool'))"] -->|"❌ name missing"| K[["FAIL\n'my-tool is missing—\nwas its .toml deleted?'"]]
L["test_builtin_all_filters_\nhave_inline_tests\nassert!(tested.contains(name))"] -->|"❌ no tests"| M[["FAIL\n'Add tests.my-tool\nentries'"]]
end
subgraph VERIFY ["✅ rtk verify"]
N["runs [[tests.my-tool]]\ninput → filter → compare expected"]
N -->|"❌ mismatch"| O[["FAIL\nshows actual vs expected"]]
N -->|"✅ pass"| P[["60/60 tests passed"]]
end
G --> H
G --> J
G --> L
G --> N
subgraph RUNTIME ["⚡ rtk my-tool --verbose"]
Q["Claude Code hook\nmy-tool ... → rtk my-tool ..."] --> R
R["TomlFilterRegistry::load()\n① .rtk/filters.toml (project)\n② ~/.config/rtk/filters.toml (user)\n③ BUILTIN_TOML (binary)\n④ passthrough"] --> S
S{"match_command\n'^my-tool\\b'\nmatches?"} -->|"No match"| T[["exec raw\n(passthrough)"]]
S -->|"✅ match"| U["exec command\ncapture stdout"]
U --> V
subgraph PIPELINE ["8-stage filter pipeline"]
V["strip_ansi"] --> W["replace"]
W --> X{"match_output\nshort-circuit?"}
X -->|"✅ pattern matched"| Y[["emit message\nstop pipeline"]]
X -->|"no match"| Z["strip/keep_lines"]
Z --> AA["truncate_lines_at"]
AA --> AB["tail_lines"]
AB --> AC["max_lines"]
AC --> AD{"output\nempty?"}
AD -->|"yes"| AE[["emit on_empty"]]
AD -->|"no"| AF[["print filtered\noutput + exit code"]]
end
end
G --> Q
style BUILD fill:#1e3a5f,color:#fff
style TESTS fill:#1a3a1a,color:#fff
style VERIFY fill:#2d1b69,color:#fff
style RUNTIME fill:#3a1a1a,color:#fff
style PIPELINE fill:#4a2a00,color:#fff
style D fill:#8b0000,color:#fff
style I fill:#8b0000,color:#fff
style K fill:#8b0000,color:#fff
style M fill:#8b0000,color:#fff
style O fill:#8b0000,color:#fff
```
## Step-by-step summary
| Step | Who | What happens | Fails if |
|------|-----|--------------|----------|
| 1 | Contributor | Creates `src/filters/my-tool.toml` | — |
| 2 | `build.rs` | Concatenates all `.toml` files alphabetically | TOML syntax error, duplicate filter name |
| 3 | `rustc` | Embeds result in binary via `BUILTIN_TOML` const | — |
| 4 | `cargo test` | 3 guards check count, names, inline test presence | Count not updated, name not in list, no `[[tests.*]]` |
| 5 | `rtk verify` | Runs each `[[tests.my-tool]]` entry | Filter logic doesn't match expected output |
| 6 | Runtime | Hook rewrites command, registry looks up filter, pipeline runs | No match → passthrough (not an error) |
## Filter lookup priority at runtime
```mermaid
flowchart LR
CMD["rtk my-tool args"] --> P1
P1{"1. .rtk/filters.toml\n(project-local)"}
P1 -->|"✅ match"| WIN["apply filter"]
P1 -->|"no match"| P2
P2{"2. ~/.config/rtk/filters.toml\n(user-global)\n(macOS alt: ~/Library/Application Support/rtk/filters.toml)"}
P2 -->|"✅ match"| WIN
P2 -->|"no match"| P3
P3{"3. BUILTIN_TOML\n(binary)"}
P3 -->|"✅ match"| WIN
P3 -->|"no match"| P4[["exec raw\n(passthrough)"]]
```
First match wins. A project filter with the same name as a built-in shadows the built-in and triggers a warning:
```
[rtk] warning: filter 'make' is shadowing a built-in filter
```
-450
View File
@@ -1,450 +0,0 @@
schema_version = 1
[filters.make]
description = "Compact make output"
match_command = "^make\\b"
strip_lines_matching = [
"^make\\[\\d+\\]:",
"^\\s*$",
"^Nothing to be done",
]
max_lines = 50
on_empty = "make: ok"
[filters.terraform-plan]
description = "Compact Terraform plan output"
match_command = "^terraform\\s+plan(\\s|$)"
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"
# ---------------------------------------------------------------------------
# Inline tests
# ---------------------------------------------------------------------------
[[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."
[[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."
[[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'\nmake[1]: Nothing to be done for 'all'.\nmake[1]: Leaving directory '/home/user'\n"
expected = "make: ok"
# ---------------------------------------------------------------------------
# #240 OpenTofu (mirror of Terraform filters)
# ---------------------------------------------------------------------------
[filters.tofu-plan]
description = "Compact OpenTofu plan output"
match_command = "^tofu\\s+plan(\\s|$)"
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"
[filters.tofu-init]
description = "Compact OpenTofu init output"
match_command = "^tofu\\s+init(\\s|$)"
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"
[filters.tofu-validate]
description = "Compact OpenTofu validate output"
match_command = "^tofu\\s+validate(\\s|$)"
strip_ansi = true
match_output = [
{ pattern = "Success! The configuration is valid", message = "ok (valid)" },
]
[filters.tofu-fmt]
description = "Compact OpenTofu fmt output"
match_command = "^tofu\\s+fmt(\\s|$)"
strip_ansi = true
on_empty = "tofu fmt: ok (no changes)"
max_lines = 30
# ---------------------------------------------------------------------------
# #284 du
# ---------------------------------------------------------------------------
[filters.du]
description = "Compact du output"
match_command = "^du\\b"
strip_lines_matching = ["^\\s*$"]
truncate_lines_at = 120
max_lines = 40
# ---------------------------------------------------------------------------
# #281 fail2ban-client
# ---------------------------------------------------------------------------
[filters.fail2ban-client]
description = "Compact fail2ban-client output"
match_command = "^fail2ban-client\\b"
strip_lines_matching = ["^\\s*$"]
max_lines = 30
# ---------------------------------------------------------------------------
# #282 iptables
# ---------------------------------------------------------------------------
[filters.iptables]
description = "Compact iptables output"
match_command = "^iptables(\\s|$)"
strip_lines_matching = [
"^\\s*$",
"^Chain DOCKER",
"^Chain BR-",
]
max_lines = 50
truncate_lines_at = 120
# ---------------------------------------------------------------------------
# #310 Elixir mix
# ---------------------------------------------------------------------------
[filters.mix-format]
description = "Compact mix format output"
match_command = "^mix\\s+format(\\s|$)"
on_empty = "mix format: ok"
max_lines = 20
[filters.mix-compile]
description = "Compact mix compile output"
match_command = "^mix\\s+compile(\\s|$)"
strip_ansi = true
strip_lines_matching = [
"^Compiling \\d+ file",
"^\\s*$",
"^Generated\\s",
]
max_lines = 40
on_empty = "mix compile: ok"
# ---------------------------------------------------------------------------
# #280 Shopify theme
# ---------------------------------------------------------------------------
[filters.shopify-theme]
description = "Compact shopify theme push/pull output"
match_command = "^shopify\\s+theme\\s+(push|pull)"
strip_ansi = true
strip_lines_matching = [
"^\\s*$",
"^\\s*Uploading",
"^\\s*Downloading",
]
tail_lines = 5
max_lines = 15
on_empty = "shopify theme: ok"
# ---------------------------------------------------------------------------
# #231 PlatformIO
# ---------------------------------------------------------------------------
[filters.pio-run]
description = "Compact PlatformIO build output"
match_command = "^pio\\s+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"
# ---------------------------------------------------------------------------
# #338 Maven
# ---------------------------------------------------------------------------
[filters.mvn-build]
description = "Compact Maven build output"
match_command = "^mvn\\s+(compile|package|clean|install)\\b"
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"
# ---------------------------------------------------------------------------
# Inline tests — new filters
# ---------------------------------------------------------------------------
[[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."
[[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"
[[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!"
[[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"
[[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\""
[[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"
[[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."
[[tests.du]]
name = "single line passthrough"
input = "128K\t."
expected = "128K\t."
[[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"
[[tests.fail2ban-client]]
name = "single line passthrough"
input = "Shutdown successful"
expected = "Shutdown successful"
[[tests.iptables]]
name = "strips Docker chains, preserves real rules"
input = """
Chain INPUT (policy ACCEPT)
num target prot opt source destination
1 ACCEPT all -- 0.0.0.0/0 0.0.0.0/0
Chain DOCKER (1 references)
DOCKER all -- 0.0.0.0/0 0.0.0.0/0
Chain BR-abcdef (0 references)
"""
expected = "Chain INPUT (policy ACCEPT)\nnum target prot opt source destination\n1 ACCEPT all -- 0.0.0.0/0 0.0.0.0/0\n DOCKER all -- 0.0.0.0/0 0.0.0.0/0"
[[tests.iptables]]
name = "preserves FORWARD and OUTPUT chains"
input = "Chain FORWARD (policy DROP)\n1 ACCEPT tcp\nChain OUTPUT (policy ACCEPT)\n1 ACCEPT all"
expected = "Chain FORWARD (policy DROP)\n1 ACCEPT tcp\nChain OUTPUT (policy ACCEPT)\n1 ACCEPT all"
[[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"
[[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"
[[tests.mix-compile]]
name = "on_empty when only noise"
input = "Compiling 3 files (.ex)\nGenerated my_app app\n"
expected = "mix compile: ok"
[[tests.shopify-theme]]
name = "strips upload/download lines, keeps tail"
input = """
Uploading assets/app.css
Uploading assets/app.js
Uploading templates/index.liquid
Downloading assets/old.css
Theme 'Development' (id: 12345) pushed to store.example.myshopify.com
"""
expected = "Theme 'Development' (id: 12345) pushed to store.example.myshopify.com"
[[tests.shopify-theme]]
name = "on_empty when all stripped"
input = "Uploading assets/app.css\nDownloading assets/base.css\n"
expected = "shopify theme: 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"
[[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"
[[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"
[[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"
+52
View File
@@ -0,0 +1,52 @@
# Built-in Filters
Each `.toml` file in this directory defines one filter and its inline tests.
Files are concatenated alphabetically by `build.rs` into a single TOML blob embedded in the binary.
## Adding a filter
1. Copy any existing `.toml` file and rename it (e.g. `my-tool.toml`)
2. Update the three required fields: `description`, `match_command`, and at least one action field
3. Add `[[tests.my-tool]]` entries to verify the filter behaves correctly
4. Run `cargo test` — the build step validates TOML syntax and runs inline tests
## File format
```toml
[filters.my-tool]
description = "Short description of what this filter does"
match_command = "^my-tool\\b" # regex matched against the full command string
strip_ansi = true # optional: strip ANSI escape codes first
strip_lines_matching = [ # optional: drop lines matching any of these regexes
"^\\s*$",
"^noise pattern",
]
max_lines = 40 # optional: keep only the first N lines after filtering
on_empty = "my-tool: ok" # optional: message to emit when output is empty after filtering
[[tests.my-tool]]
name = "descriptive test name"
input = "raw command output here"
expected = "expected filtered output"
```
## Available filter fields
| Field | Type | Description |
|-------|------|-------------|
| `description` | string | Human-readable description |
| `match_command` | regex | Matches the command string (e.g. `"^docker\\s+inspect"`) |
| `strip_ansi` | bool | Strip ANSI escape codes before processing |
| `strip_lines_matching` | regex[] | Drop lines matching any regex |
| `keep_lines_matching` | regex[] | Keep only lines matching at least one regex |
| `replace` | array | Regex substitutions (`{ pattern, replacement }`) |
| `match_output` | array | Short-circuit rules (`{ pattern, message }`) |
| `truncate_lines_at` | int | Truncate lines longer than N characters |
| `max_lines` | int | Keep only the first N lines |
| `tail_lines` | int | Keep only the last N lines (applied after other filters) |
| `on_empty` | string | Fallback message when filtered output is empty |
## Naming convention
Use the command name as the filename: `terraform-plan.toml`, `docker-inspect.toml`, `mix-compile.toml`.
For commands with subcommands, prefer `<cmd>-<subcommand>.toml` over grouping multiple filters in one file.
+34
View File
@@ -0,0 +1,34 @@
[filters.ansible-playbook]
description = "Compact ansible-playbook output"
match_command = "^ansible-playbook\\b"
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"
[[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"
+16
View File
@@ -0,0 +1,16 @@
[filters.du]
description = "Compact du output"
match_command = "^du\\b"
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."
[[tests.du]]
name = "single line passthrough"
input = "128K\t."
expected = "128K\t."
+15
View File
@@ -0,0 +1,15 @@
[filters.fail2ban-client]
description = "Compact fail2ban-client output"
match_command = "^fail2ban-client\\b"
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"
[[tests.fail2ban-client]]
name = "single line passthrough"
input = "Shutdown successful"
expected = "Shutdown successful"
+22
View File
@@ -0,0 +1,22 @@
[filters.gcloud]
description = "Compact gcloud output"
match_command = "^gcloud\\b"
strip_ansi = true
strip_lines_matching = ["^\\s*$"]
truncate_lines_at = 120
max_lines = 30
[[tests.gcloud]]
name = "strips blank lines, preserves output"
input = """
Updated property [core/project].
NAME REGION STATUS
my-cluster us-central1 RUNNING
"""
expected = "Updated property [core/project].\nNAME REGION STATUS\nmy-cluster us-central1 RUNNING"
[[tests.gcloud]]
name = "single line passthrough"
input = "Listed 0 items."
expected = "Listed 0 items."
+29
View File
@@ -0,0 +1,29 @@
[filters.helm]
description = "Compact helm output"
match_command = "^helm\\b"
strip_ansi = true
strip_lines_matching = [
"^\\s*$",
"^W\\d{4}",
]
truncate_lines_at = 120
max_lines = 40
[[tests.helm]]
name = "strips blank lines, preserves release info"
input = """
NAME: my-release
LAST DEPLOYED: Mon Jan 15 10:30:00 2024
NAMESPACE: default
STATUS: deployed
REVISION: 3
NOTES:
Application is running.
"""
expected = "NAME: my-release\nLAST DEPLOYED: Mon Jan 15 10:30:00 2024\nNAMESPACE: default\nSTATUS: deployed\nREVISION: 3\nNOTES:\nApplication is running."
[[tests.helm]]
name = "strips glog W-prefix warnings"
input = "W0115 10:30:00 warning message from internal\nNAME: my-chart\nSTATUS: deployed"
expected = "NAME: my-chart\nSTATUS: deployed"
+27
View File
@@ -0,0 +1,27 @@
[filters.iptables]
description = "Compact iptables output"
match_command = "^iptables\\b"
strip_lines_matching = [
"^\\s*$",
"^Chain DOCKER",
"^Chain BR-",
]
max_lines = 50
truncate_lines_at = 120
[[tests.iptables]]
name = "strips Docker chains, preserves real rules"
input = """
Chain INPUT (policy ACCEPT)
num target prot opt source destination
1 ACCEPT all -- 0.0.0.0/0 0.0.0.0/0
Chain DOCKER (1 references)
DOCKER all -- 0.0.0.0/0 0.0.0.0/0
Chain BR-abcdef (0 references)
"""
expected = "Chain INPUT (policy ACCEPT)\nnum target prot opt source destination\n1 ACCEPT all -- 0.0.0.0/0 0.0.0.0/0\n DOCKER all -- 0.0.0.0/0 0.0.0.0/0"
[[tests.iptables]]
name = "preserves FORWARD and OUTPUT chains"
input = "Chain FORWARD (policy DROP)\n1 ACCEPT tcp\nChain OUTPUT (policy ACCEPT)\n1 ACCEPT all"
expected = "Chain FORWARD (policy DROP)\n1 ACCEPT tcp\nChain OUTPUT (policy ACCEPT)\n1 ACCEPT all"
+41
View File
@@ -0,0 +1,41 @@
[filters.make]
description = "Compact make output"
match_command = "^make\\b"
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"
+27
View File
@@ -0,0 +1,27 @@
[filters.mix-compile]
description = "Compact mix compile output"
match_command = "^mix\\s+compile(\\s|$)"
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"
[[tests.mix-compile]]
name = "on_empty when only noise"
input = "Compiling 3 files (.ex)\nGenerated my_app app\n"
expected = "mix compile: ok"
+15
View File
@@ -0,0 +1,15 @@
[filters.mix-format]
description = "Compact mix format output"
match_command = "^mix\\s+format(\\s|$)"
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"
+44
View File
@@ -0,0 +1,44 @@
[filters.mvn-build]
description = "Compact Maven build output"
match_command = "^mvn\\s+(compile|package|clean|install)\\b"
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"
[[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"
+40
View File
@@ -0,0 +1,40 @@
[filters.pio-run]
description = "Compact PlatformIO build output"
match_command = "^pio\\s+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"
[[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"
+35
View File
@@ -0,0 +1,35 @@
[filters.pre-commit]
description = "Compact pre-commit output"
match_command = "^pre-commit\\b"
strip_ansi = true
strip_lines_matching = [
"^\\[INFO\\] Installing environment",
"^\\[INFO\\] Once installed this environment will be reused",
"^\\[INFO\\] This may take a few minutes",
"^\\s*$",
]
max_lines = 40
[[tests.pre-commit]]
name = "strips INFO install noise, keeps hook results"
input = """
[INFO] Installing environment for https://github.com/psf/black.
[INFO] Once installed this environment will be reused.
[INFO] This may take a few minutes...
Trim Trailing Whitespace.................................................Passed
Fix End of Files.........................................................Passed
Check Yaml...............................................................Failed
- hook id: check-yaml
- exit code: 1
"""
expected = "Trim Trailing Whitespace.................................................Passed\nFix End of Files.........................................................Passed\nCheck Yaml...............................................................Failed\n- hook id: check-yaml\n- exit code: 1"
[[tests.pre-commit]]
name = "all passed — no INFO noise"
input = """
[INFO] Installing environment for https://github.com/pre-commit/mirrors-isort.
[INFO] Once installed this environment will be reused.
isort....................................................................Passed
black....................................................................Passed
"""
expected = "isort....................................................................Passed\nblack....................................................................Passed"
+41
View File
@@ -0,0 +1,41 @@
[filters.quarto-render]
description = "Compact quarto render output"
match_command = "^quarto\\s+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"
+29
View File
@@ -0,0 +1,29 @@
[filters.shopify-theme]
description = "Compact shopify theme push/pull output"
match_command = "^shopify\\s+theme\\s+(push|pull)"
strip_ansi = true
strip_lines_matching = [
"^\\s*$",
"^\\s*Uploading",
"^\\s*Downloading",
]
tail_lines = 5
max_lines = 15
on_empty = "shopify theme: ok"
[[tests.shopify-theme]]
name = "strips upload/download lines, keeps tail"
input = """
Uploading assets/app.css
Uploading assets/app.js
Uploading templates/index.liquid
Downloading assets/old.css
Theme 'Development' (id: 12345) pushed to store.example.myshopify.com
"""
expected = "Theme 'Development' (id: 12345) pushed to store.example.myshopify.com"
[[tests.shopify-theme]]
name = "on_empty when all stripped"
input = "Uploading assets/app.css\nDownloading assets/base.css\n"
expected = "shopify theme: ok"
+16
View File
@@ -0,0 +1,16 @@
[filters.sops]
description = "Compact sops output"
match_command = "^sops\\b"
strip_ansi = true
strip_lines_matching = ["^\\s*$"]
max_lines = 40
[[tests.sops]]
name = "strips blank lines"
input = "mac: xyz123\n\nversion: 3.8.1"
expected = "mac: xyz123\nversion: 3.8.1"
[[tests.sops]]
name = "preserves non-blank output unchanged"
input = "mac: abc123\nversion: 3.8.1"
expected = "mac: abc123\nversion: 3.8.1"
+35
View File
@@ -0,0 +1,35 @@
[filters.terraform-plan]
description = "Compact Terraform plan output"
match_command = "^terraform\\s+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."
[[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."
+16
View File
@@ -0,0 +1,16 @@
[filters.tofu-fmt]
description = "Compact OpenTofu fmt output"
match_command = "^tofu\\s+fmt(\\s|$)"
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"
+38
View File
@@ -0,0 +1,38 @@
[filters.tofu-init]
description = "Compact OpenTofu init output"
match_command = "^tofu\\s+init(\\s|$)"
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!"
[[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"
+35
View File
@@ -0,0 +1,35 @@
[filters.tofu-plan]
description = "Compact OpenTofu plan output"
match_command = "^tofu\\s+plan(\\s|$)"
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."
[[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"
+17
View File
@@ -0,0 +1,17 @@
[filters.tofu-validate]
description = "Compact OpenTofu validate output"
match_command = "^tofu\\s+validate(\\s|$)"
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\""
+39
View File
@@ -0,0 +1,39 @@
[filters.trunk-build]
description = "Compact trunk build output"
match_command = "^trunk\\s+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)"
[[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"
+87 -2
View File
@@ -12,6 +12,37 @@ const REWRITE_HOOK: &str = include_str!("../hooks/rtk-rewrite.sh");
// Embedded slim RTK awareness instructions
const RTK_SLIM: &str = include_str!("../hooks/rtk-awareness.md");
/// Template written by `rtk init` when no filters.toml exists yet.
const FILTERS_TEMPLATE: &str = r#"# Project-local RTK filters — commit this file with your repo.
# Filters here override user-global and built-in filters.
# Docs: https://github.com/rtk-ai/rtk#custom-filters
schema_version = 1
# Example: suppress build noise from a custom tool
# [filters.my-tool]
# description = "Compact my-tool output"
# match_command = "^my-tool\\s+build"
# strip_ansi = true
# strip_lines_matching = ["^\\s*$", "^Downloading", "^Installing"]
# max_lines = 30
# on_empty = "my-tool: ok"
"#;
/// Template for user-global filters (~/.config/rtk/filters.toml).
const FILTERS_GLOBAL_TEMPLATE: &str = r#"# User-global RTK filters — apply to all your projects.
# Project-local .rtk/filters.toml takes precedence over these.
# Docs: https://github.com/rtk-ai/rtk#custom-filters
schema_version = 1
# Example: suppress noise from a tool you use everywhere
# [filters.my-global-tool]
# description = "Compact my-global-tool output"
# match_command = "^my-global-tool\\b"
# strip_ansi = true
# strip_lines_matching = ["^\\s*$"]
# max_lines = 40
"#;
/// Control flow for settings.json patching
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum PatchMode {
@@ -666,8 +697,10 @@ fn run_default_mode(_global: bool, _patch_mode: PatchMode, _verbose: u8) -> Resu
#[cfg(unix)]
fn run_default_mode(global: bool, patch_mode: PatchMode, verbose: u8) -> Result<()> {
if !global {
// Local init: unchanged behavior (full injection into ./CLAUDE.md)
return run_claude_md_mode(false, verbose);
// Local init: inject CLAUDE.md + generate project-local filters template
run_claude_md_mode(false, verbose)?;
generate_project_filters_template(verbose)?;
return Ok(());
}
let claude_dir = resolve_claude_dir()?;
@@ -717,11 +750,63 @@ fn run_default_mode(global: bool, patch_mode: PatchMode, verbose: u8) -> Result<
}
}
// 6. Generate user-global filters template (~/.config/rtk/filters.toml)
generate_global_filters_template(verbose)?;
println!(); // Final newline
Ok(())
}
/// Generate .rtk/filters.toml template in the current directory if not present.
fn generate_project_filters_template(verbose: u8) -> Result<()> {
let rtk_dir = std::path::Path::new(".rtk");
let path = rtk_dir.join("filters.toml");
if path.exists() {
if verbose > 0 {
eprintln!(".rtk/filters.toml already exists, skipping template");
}
return Ok(());
}
fs::create_dir_all(rtk_dir)
.with_context(|| format!("Failed to create directory: {}", rtk_dir.display()))?;
fs::write(&path, FILTERS_TEMPLATE)
.with_context(|| format!("Failed to write {}", path.display()))?;
println!(
" filters: {} (template, edit to add project filters)",
path.display()
);
Ok(())
}
/// Generate ~/.config/rtk/filters.toml template if not present.
fn generate_global_filters_template(verbose: u8) -> Result<()> {
let config_dir = dirs::config_dir().unwrap_or_else(|| std::path::PathBuf::from(".config"));
let rtk_dir = config_dir.join("rtk");
let path = rtk_dir.join("filters.toml");
if path.exists() {
if verbose > 0 {
eprintln!("{} already exists, skipping template", path.display());
}
return Ok(());
}
fs::create_dir_all(&rtk_dir)
.with_context(|| format!("Failed to create directory: {}", rtk_dir.display()))?;
fs::write(&path, FILTERS_GLOBAL_TEMPLATE)
.with_context(|| format!("Failed to write {}", path.display()))?;
println!(
" filters: {} (template, edit to add user-global filters)",
path.display()
);
Ok(())
}
/// Hook-only mode: just the hook, no RTK.md
#[cfg(not(unix))]
fn run_hook_only_mode(_global: bool, _patch_mode: PatchMode, _verbose: u8) -> Result<()> {
+231 -14
View File
@@ -1,12 +1,13 @@
/// TOML-based filter DSL for RTK.
///
/// Provides a declarative pipeline of 8 stages that can be configured
/// via `.rtk/filters.toml` (project-local) or built-in TOML embedded in the binary.
/// via TOML files. Lookup priority (first match wins):
/// 1. `.rtk/filters.toml` — project-local, committable with the repo
/// 2. `~/.config/rtk/filters.toml` — user-global, applies to all projects
/// 3. Built-in TOML — `src/filters/*.toml`, concatenated by build.rs and embedded at compile time
/// 4. Passthrough — no match, handled by caller
///
/// Lookup priority (first match wins):
/// 1. `.rtk/filters.toml` (PWD — project-local, committable)
/// 2. Built-in TOML (`src/builtin_filters.toml`, embedded at compile time)
/// 3. Passthrough (no match — handled by caller)
/// `rtk init` generates a commented template for both levels (project or global).
///
/// Environment variables:
/// - `RTK_NO_TOML=1` — bypass TOML engine entirely
@@ -26,6 +27,9 @@ use regex::{Regex, RegexSet};
use serde::Deserialize;
use std::collections::BTreeMap;
// Built-in filters: concatenated from src/filters/*.toml by build.rs at compile time.
const BUILTIN_TOML: &str = include_str!(concat!(env!("OUT_DIR"), "/builtin_filters.toml"));
// ---------------------------------------------------------------------------
// Deserialization types (TOML schema)
// ---------------------------------------------------------------------------
@@ -179,8 +183,19 @@ impl TomlFilterRegistry {
}
}
// Priority 2: built-in (embedded at compile time)
let builtin = include_str!("builtin_filters.toml");
// Priority 2: user-global ~/.config/rtk/filters.toml
if let Some(config_dir) = dirs::config_dir() {
let global_path = config_dir.join("rtk").join("filters.toml");
if let Ok(content) = std::fs::read_to_string(&global_path) {
match Self::parse_and_compile(&content, "user-global") {
Ok(f) => filters.extend(f),
Err(e) => eprintln!("[rtk] warning: {}: {}", global_path.display(), e),
}
}
}
// Priority 3: built-in (embedded at compile time)
let builtin = BUILTIN_TOML;
match Self::parse_and_compile(builtin, "builtin") {
Ok(f) => filters.extend(f),
Err(e) => eprintln!("[rtk] warning: builtin filters: {}", e),
@@ -211,6 +226,61 @@ impl TomlFilterRegistry {
}
}
/// Commands already handled by dedicated Rust modules (routed by Clap before TOML).
/// A TOML filter whose match_command matches one of these will never activate —
/// Clap routes the command before `run_fallback()` is reached.
const RUST_HANDLED_COMMANDS: &[&str] = &[
"ls",
"tree",
"read",
"smart",
"git",
"gh",
"aws",
"psql",
"pnpm",
"err",
"test",
"json",
"deps",
"env",
"find",
"diff",
"log",
"docker",
"kubectl",
"summary",
"grep",
"init",
"wget",
"wc",
"gain",
"config",
"vitest",
"prisma",
"tsc",
"next",
"lint",
"prettier",
"format",
"playwright",
"cargo",
"npm",
"npx",
"curl",
"discover",
"ruff",
"pytest",
"mypy",
"pip",
"go",
"golangci-lint",
"rewrite",
"proxy",
"verify",
"learn",
];
fn compile_filter(name: String, def: TomlFilterDef) -> Result<CompiledFilter, String> {
// Mutual exclusion: strip and keep cannot both be set
if !def.strip_lines_matching.is_empty() && !def.keep_lines_matching.is_empty() {
@@ -220,6 +290,19 @@ fn compile_filter(name: String, def: TomlFilterDef) -> Result<CompiledFilter, St
let match_regex = Regex::new(&def.match_command)
.map_err(|e| format!("invalid match_command regex: {}", e))?;
// Shadow warning: if match_command matches a Rust-handled command, this filter
// will never activate (Clap routes before run_fallback). Warn the author.
for cmd in RUST_HANDLED_COMMANDS {
if match_regex.is_match(cmd) {
eprintln!(
"[rtk] warning: filter '{}' match_command matches '{}' which is already \
handled by a Rust module this filter will never activate for that command",
name, cmd
);
break;
}
}
let replace = def
.replace
.into_iter()
@@ -416,7 +499,7 @@ pub fn run_filter_tests(filter_name_opt: Option<&str>) -> VerifyResults {
let mut tested_filter_names: std::collections::HashSet<String> =
std::collections::HashSet::new();
let builtin = include_str!("builtin_filters.toml");
let builtin = BUILTIN_TOML;
collect_test_outcomes(
builtin,
filter_name_opt,
@@ -819,12 +902,12 @@ match_command = "^cmd"
#[test]
fn test_builtin_filters_compile() {
// Compile-time safety: panics if builtin_filters.toml is broken
let builtin = include_str!("builtin_filters.toml");
// Compile-time safety: panics if any src/filters/*.toml is broken
let builtin = BUILTIN_TOML;
let result = TomlFilterRegistry::parse_and_compile(builtin, "builtin");
assert!(
result.is_ok(),
"builtin_filters.toml failed to compile: {:?}",
"builtin filters failed to compile: {:?}",
result
);
assert!(!result.unwrap().is_empty());
@@ -869,7 +952,7 @@ match_command = "^make\\b"
max_lines = 999
"#,
);
let builtin = make_filters(include_str!("builtin_filters.toml"));
let builtin = make_filters(BUILTIN_TOML);
// Simulate the registry: project first
let mut all = project;
@@ -885,7 +968,7 @@ max_lines = 999
#[test]
fn test_terraform_savings_above_60pct() {
let filters = make_filters(include_str!("builtin_filters.toml"));
let filters = make_filters(BUILTIN_TOML);
let filter = find_filter_in("terraform plan", &filters).expect("terraform-plan built-in");
// Inline fixture: realistic terraform plan with many Refreshing state lines (noise).
@@ -949,7 +1032,7 @@ max_lines = 999
#[test]
fn test_make_savings_above_60pct() {
let filters = make_filters(include_str!("builtin_filters.toml"));
let filters = make_filters(BUILTIN_TOML);
let filter = find_filter_in("make all", &filters).expect("make built-in");
let input = r#"make[1]: Entering directory '/home/user/project'
@@ -1302,4 +1385,138 @@ match_command = "^make\\b"
assert!(all_names.contains(&"make".to_string()));
assert!(!tested.contains("make"));
}
// --- Multi-file architecture tests (build.rs) ---
/// Verify BUILTIN_TOML was generated with the correct schema_version header.
/// build.rs injects it — if the const is somehow stale this fails immediately.
#[test]
fn test_builtin_toml_has_schema_version() {
assert!(
BUILTIN_TOML.contains("schema_version = 1"),
"BUILTIN_TOML must start with 'schema_version = 1' (injected by build.rs)"
);
}
/// Verify every expected filter name is present in BUILTIN_TOML.
/// This is the safeguard against accidentally deleting a filter file.
#[test]
fn test_builtin_all_expected_filters_present() {
let filters = make_filters(BUILTIN_TOML);
let names: std::collections::HashSet<&str> =
filters.iter().map(|f| f.name.as_str()).collect();
let expected = [
"ansible-playbook",
"du",
"fail2ban-client",
"gcloud",
"helm",
"iptables",
"make",
"mix-compile",
"mix-format",
"mvn-build",
"pio-run",
"pre-commit",
"quarto-render",
"shopify-theme",
"sops",
"terraform-plan",
"tofu-fmt",
"tofu-init",
"tofu-plan",
"tofu-validate",
"trunk-build",
];
for name in &expected {
assert!(
names.contains(name),
"Built-in filter '{}' is missing — was its .toml file deleted from src/filters/?",
name
);
}
}
/// Verify the exact count of built-in filters.
/// Fails if a file is added/removed without updating this test.
#[test]
fn test_builtin_filter_count() {
let filters = make_filters(BUILTIN_TOML);
assert_eq!(
filters.len(),
21,
"Expected exactly 21 built-in filters, got {}. \
Update this count when adding/removing filters in src/filters/.",
filters.len()
);
}
/// Verify that every built-in filter has at least one inline test.
/// Prevents shipping filters with zero test coverage.
#[test]
fn test_builtin_all_filters_have_inline_tests() {
let mut all_names: Vec<String> = Vec::new();
let mut tested: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut outcomes = Vec::new();
collect_test_outcomes(
BUILTIN_TOML,
None,
&mut outcomes,
&mut all_names,
&mut tested,
);
let untested: Vec<&str> = all_names
.iter()
.filter(|name| !tested.contains(name.as_str()))
.map(|s| s.as_str())
.collect();
assert!(
untested.is_empty(),
"The following built-in filters have no inline tests: {:?}\n\
Add [[tests.<name>]] entries to the corresponding src/filters/<name>.toml file.",
untested
);
}
/// Verify that adding a new filter entry to any TOML content makes it
/// immediately discoverable via find_filter_in — simulating how a new
/// src/filters/my-tool.toml would work after cargo build.
#[test]
fn test_new_filter_discoverable_after_concat() {
// Simulate build.rs: concat BUILTIN_TOML with a brand-new filter block
let new_filter = r#"
[filters.my-new-tool]
description = "Compact my-new-tool output"
match_command = "^my-new-tool\\b"
strip_lines_matching = ["^\\s*$"]
max_lines = 30
on_empty = "my-new-tool: ok"
[[tests.my-new-tool]]
name = "strips blank lines"
input = "output line 1\n\noutput line 2"
expected = "output line 1\noutput line 2"
"#;
let combined = format!("{}\n\n{}", BUILTIN_TOML, new_filter);
let filters = make_filters(&combined);
// All 21 existing filters still present + 1 new = 22
assert_eq!(
filters.len(),
22,
"Expected 22 filters after concat (21 built-in + 1 new)"
);
// New filter is discoverable
let found = find_filter_in("my-new-tool --verbose", &filters);
assert!(
found.is_some(),
"Newly added filter must be discoverable via find_filter_in"
);
assert_eq!(found.unwrap().name, "my-new-tool");
}
}