From 8de15a77dc88f16f7093795c93f14fb9cefcc116 Mon Sep 17 00:00:00 2001 From: rNoz Date: Tue, 14 Jul 2026 22:14:24 +0200 Subject: [PATCH] feat(pipe): accept built-in and trusted custom TOML filters in pipe -f rtk pipe -f only resolved the hard-coded Rust filter names, so trusted project/global TOML filters (and built-in TOML filters) were rejected with "Unknown filter" even though the hook and rtk rewrite already apply them. Resolve the name against the TOML registry when no Rust filter matches. Only built-in and trusted custom filters are loaded, so untrusted filters are still rejected. A Filter enum dispatches Rust vs TOML through the same never_worse guard, and the error message now lists the available TOML filter names. Refs #2179 --- src/cmds/system/README.md | 1 + src/cmds/system/pipe_cmd.rs | 100 ++++++++++++++++++++----- src/core/toml_filter.rs | 19 +++++ tests/pipe_custom_filter_test.rs | 123 +++++++++++++++++++++++++++++++ 4 files changed, 224 insertions(+), 19 deletions(-) create mode 100644 tests/pipe_custom_filter_test.rs diff --git a/src/cmds/system/README.md b/src/cmds/system/README.md index ae475befbd..2d6461ac22 100644 --- a/src/cmds/system/README.md +++ b/src/cmds/system/README.md @@ -8,6 +8,7 @@ - `search.rs` backs both `rtk grep` and `rtk rg`: it runs the invoked engine (never substituting one for the other) and groups its output, reading `core/config` for `limits.grep_max_results` and `limits.grep_max_per_file`. Format-altering flags (`-c`, `-l`, `-L`, `-o`, `-Z`) bypass RTK filtering and run raw. - `local_llm.rs` (`rtk smart`) uses `core/filter` for heuristic file summarization - `format_cmd.rs` is a cross-ecosystem dispatcher: auto-detects and routes to `prettier_cmd` or `ruff_cmd` (black is handled inline, not as a separate module) +- `pipe_cmd.rs` (`rtk pipe -f `) applies one named filter to stdin. `` resolves to a built-in Rust filter first, then (if none matches) to a built-in or trusted-custom TOML filter from `core/toml_filter`; Rust names win on collision. Unknown or untrusted names error and list the available filters, and `RTK_NO_TOML=1` disables TOML resolution. ## Cross-command diff --git a/src/cmds/system/pipe_cmd.rs b/src/cmds/system/pipe_cmd.rs index 563d54a10f..00fa0460a5 100644 --- a/src/cmds/system/pipe_cmd.rs +++ b/src/cmds/system/pipe_cmd.rs @@ -3,12 +3,20 @@ use std::io::Read; use crate::core::guard::never_worse; use crate::core::stream::RAW_CAP; +use crate::core::toml_filter::{self, CompiledFilter}; use crate::core::truncate::{CAP_LIST, CAP_WARNINGS}; const MAX_PIPE_MATCHES: usize = CAP_WARNINGS; const MAX_PIPE_FILES: usize = CAP_WARNINGS; const MAX_PIPE_DIRS: usize = CAP_LIST; +/// A resolved pipe filter: either a built-in Rust function or a loaded +/// (built-in or trusted custom) TOML filter. +pub enum Filter { + Rust(fn(&str) -> String), + Toml(&'static CompiledFilter), +} + pub fn resolve_filter(name: &str) -> Option String> { match name { "cargo-test" | "cargo" => Some(crate::cmds::rust::cargo_cmd::filter_cargo_test), @@ -235,12 +243,40 @@ fn identity_filter(input: &str) -> String { input.to_string() } -fn apply_filter(filter_fn: fn(&str) -> String, input: &str) -> String { - std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| filter_fn(input))) - .unwrap_or_else(|_| { - eprintln!("[rtk] warning: filter panicked — passing through raw output"); - input.to_string() - }) +fn apply_filter(filter: &Filter, input: &str) -> String { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| match filter { + Filter::Rust(f) => f(input), + Filter::Toml(cf) => toml_filter::apply_filter(cf, input), + })) + .unwrap_or_else(|_| { + eprintln!("[rtk] warning: filter panicked — passing through raw output"); + input.to_string() + }) +} + +const BUILTIN_PIPE_FILTERS: &str = "cargo-test (alias cargo), pytest, go-test, go-build, tsc, \ + vitest, grep, rg, find, fd, git-log, git-diff, git-status, log, mypy, ruff-check, \ + ruff-format, prettier, phpunit, pest, paratest, php-test, ecs, phpstan, pint"; + +fn unknown_filter_message(name: &str) -> String { + let toml_names = if toml_filter::toml_disabled() { + Vec::new() + } else { + toml_filter::loaded_filter_names() + }; + if toml_names.is_empty() { + format!( + "Unknown filter '{}'. Built-in: {}", + name, BUILTIN_PIPE_FILTERS + ) + } else { + format!( + "Unknown filter '{}'. Built-in: {}. TOML (built-in + trusted custom): {}", + name, + BUILTIN_PIPE_FILTERS, + toml_names.join(", ") + ) + } } pub fn run(filter_name: Option<&str>, passthrough: bool) -> Result<()> { @@ -259,20 +295,24 @@ pub fn run(filter_name: Option<&str>, passthrough: bool) -> Result<()> { anyhow::bail!("stdin exceeds {} byte limit", RAW_CAP); } - let filter_fn = match filter_name { - Some(name) => resolve_filter(name).ok_or_else(|| { - anyhow::anyhow!( - "Unknown filter '{}'. Available: cargo-test, pytest, go-test, go-build, \ - tsc, vitest, grep, rg, find, fd, git-log, git-diff, git-status, \ - log, mypy, ruff-check, ruff-format, prettier, phpunit, pest, \ - paratest, php-test, ecs, phpstan, pint", - name - ) - })?, - None => auto_detect_filter(&buf), + let filter = match filter_name { + // Built-in Rust filters take precedence over a TOML filter of the same + // name (mirrors the hook's resolve order). Fall back to the TOML + // registry by name unless the TOML engine is disabled (RTK_NO_TOML=1). + Some(name) => match resolve_filter(name) { + Some(f) => Filter::Rust(f), + None if toml_filter::toml_disabled() => { + anyhow::bail!("{}", unknown_filter_message(name)) + } + None => match toml_filter::find_filter_by_name(name) { + Some(cf) => Filter::Toml(cf), + None => anyhow::bail!("{}", unknown_filter_message(name)), + }, + }, + None => Filter::Rust(auto_detect_filter(&buf)), }; - let output = apply_filter(filter_fn, &buf); + let output = apply_filter(&filter, &buf); let shown = never_worse(&buf, &output); print!("{}", shown); Ok(()) @@ -548,10 +588,32 @@ mod tests { panic!("filter bug"); } let input = "some output\n"; - let result = super::apply_filter(panicking_filter, input); + let result = super::apply_filter(&Filter::Rust(panicking_filter), input); assert_eq!(result, input); } + #[test] + fn test_unknown_filter_message_lists_builtin() { + let msg = super::unknown_filter_message("nope"); + assert!(msg.contains("Unknown filter 'nope'"), "msg={}", msg); + assert!(msg.contains("cargo-test"), "msg={}", msg); + } + + #[test] + fn test_toml_filter_resolves_by_name() { + // A built-in TOML filter must be resolvable by name and dispatch through + // the TOML pipeline (built-in filters are always trusted). + let cf = + toml_filter::find_filter_by_name("make").expect("built-in 'make' filter must load"); + let out = super::apply_filter(&Filter::Toml(cf), "make[1]: Entering directory '/x'\nreal"); + assert!(!out.is_empty(), "out={}", out); + } + + #[test] + fn test_unknown_toml_filter_name_returns_none() { + assert!(toml_filter::find_filter_by_name("definitely-not-a-filter").is_none()); + } + fn count_tokens(s: &str) -> usize { s.split_whitespace().count() } diff --git a/src/core/toml_filter.rs b/src/core/toml_filter.rs index b5a9be6ca8..366b600cac 100644 --- a/src/core/toml_filter.rs +++ b/src/core/toml_filter.rs @@ -801,6 +801,25 @@ pub fn find_matching_filter(command: &str) -> Option<&'static CompiledFilter> { result } +/// Find a loaded filter by its exact name, ignoring `match_command`. +/// +/// Only built-in TOML filters and trusted project/global custom filters are +/// present in the registry, so untrusted custom filters are never returned. +/// On duplicate names the load order wins (project > global > built-in), +/// matching the precedence used by `find_matching_filter`. +pub fn find_filter_by_name(name: &str) -> Option<&'static CompiledFilter> { + REGISTRY.filters.iter().find(|f| f.name == name) +} + +/// Sorted, de-duplicated names of every loaded filter (built-in + trusted +/// custom). Used to surface available filter names in error messages. +pub fn loaded_filter_names() -> Vec<&'static str> { + let mut names: Vec<&'static str> = REGISTRY.filters.iter().map(|f| f.name.as_str()).collect(); + names.sort_unstable(); + names.dedup(); + names +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- diff --git a/tests/pipe_custom_filter_test.rs b/tests/pipe_custom_filter_test.rs new file mode 100644 index 0000000000..ad975e0936 --- /dev/null +++ b/tests/pipe_custom_filter_test.rs @@ -0,0 +1,123 @@ +#![cfg(unix)] +//! #2179: `rtk pipe -f ` must accept built-in TOML filters and trusted +//! project/global custom filters, not just the hard-coded Rust filter names. + +use std::io::Write; +use std::process::{Command, Stdio}; + +fn write_probe_filter(dir: &std::path::Path) { + let rtk_dir = dir.join(".rtk"); + std::fs::create_dir_all(&rtk_dir).unwrap(); + std::fs::write( + rtk_dir.join("filters.toml"), + "schema_version = 1\n\ + [filters.probe-echo]\n\ + description = \"probe echo filter\"\n\ + match_command = \"^echo\"\n\ + strip_lines_matching = [\"^noise\"]\n", + ) + .unwrap(); +} + +const PROBE_INPUT: &str = "PROBE keep\nnoise a\nnoise b\nnoise c\nnoise d\nnoise e\n"; + +/// Run `rtk pipe -f ` in `dir` with `stdin`, returning (stdout, stderr, success). +fn run_pipe(dir: &std::path::Path, name: &str, stdin: &str, trust: bool) -> (String, String, bool) { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_rtk")); + cmd.args(["pipe", "-f", name]) + .current_dir(dir) + .env("HOME", dir) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if trust { + // Trust project filters without mutating the real trust store. + cmd.env("RTK_TRUST_PROJECT_FILTERS", "1").env("CI", "1"); + } + let mut child = cmd.spawn().unwrap(); + child + .stdin + .take() + .unwrap() + .write_all(stdin.as_bytes()) + .unwrap(); + let out = child.wait_with_output().unwrap(); + ( + String::from_utf8_lossy(&out.stdout).into_owned(), + String::from_utf8_lossy(&out.stderr).into_owned(), + out.status.success(), + ) +} + +#[test] +fn trusted_custom_filter_is_applied() { + let dir = tempfile::tempdir().unwrap(); + write_probe_filter(dir.path()); + let (stdout, _stderr, ok) = run_pipe(dir.path(), "probe-echo", PROBE_INPUT, true); + assert!(ok, "pipe should succeed for trusted custom filter"); + assert!(stdout.contains("PROBE keep"), "stdout={stdout}"); + assert!( + !stdout.contains("noise"), + "noise lines should be stripped: {stdout}" + ); +} + +#[test] +fn untrusted_custom_filter_is_rejected() { + let dir = tempfile::tempdir().unwrap(); + write_probe_filter(dir.path()); + let (_stdout, stderr, ok) = run_pipe(dir.path(), "probe-echo", PROBE_INPUT, false); + assert!(!ok, "untrusted custom filter must not be accepted"); + assert!( + stderr.contains("Unknown filter 'probe-echo'"), + "stderr={stderr}" + ); +} + +#[test] +fn builtin_toml_filter_works_without_trust() { + // Built-in TOML filters (e.g. `make`) are always trusted and usable via -f. + let dir = tempfile::tempdir().unwrap(); + let input = "make[1]: Entering directory '/x'\ngcc -c foo.c\nmake[1]: Leaving directory '/x'\n"; + let (stdout, _stderr, ok) = run_pipe(dir.path(), "make", input, false); + assert!(ok, "built-in TOML filter should work without trust"); + assert!(!stdout.is_empty(), "stdout={stdout}"); +} + +#[test] +fn rtk_no_toml_disables_toml_filters_in_pipe() { + // RTK_NO_TOML=1 must bypass the TOML engine, so even a built-in TOML + // filter name becomes unknown via pipe -f. + let dir = tempfile::tempdir().unwrap(); + let mut cmd = Command::new(env!("CARGO_BIN_EXE_rtk")); + cmd.args(["pipe", "-f", "make"]) + .current_dir(dir.path()) + .env("HOME", dir.path()) + .env("RTK_NO_TOML", "1") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut child = cmd.spawn().unwrap(); + child + .stdin + .take() + .unwrap() + .write_all(b"make[1]: Entering directory '/x'\n") + .unwrap(); + let out = child.wait_with_output().unwrap(); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + !out.status.success(), + "RTK_NO_TOML must reject TOML filters" + ); + assert!(stderr.contains("Unknown filter 'make'"), "stderr={stderr}"); +} + +#[test] +fn builtin_rust_filter_still_works() { + let dir = tempfile::tempdir().unwrap(); + let input = "src/main.rs:42:fn main() {\nsrc/lib.rs:10:pub fn helper() {}\n"; + let (stdout, _stderr, ok) = run_pipe(dir.path(), "grep", input, false); + assert!(ok, "built-in Rust filter must keep working"); + assert!(stdout.contains("main.rs"), "stdout={stdout}"); +}