diff --git a/src/cmds/system/pipe_cmd.rs b/src/cmds/system/pipe_cmd.rs index 563d54a10f..5b57a3e09b 100644 --- a/src/cmds/system/pipe_cmd.rs +++ b/src/cmds/system/pipe_cmd.rs @@ -1,8 +1,10 @@ use anyhow::Result; use std::io::Read; +use std::path::Path; 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; @@ -235,7 +237,7 @@ fn identity_filter(input: &str) -> String { input.to_string() } -fn apply_filter(filter_fn: fn(&str) -> String, input: &str) -> String { +fn apply_rust_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"); @@ -243,7 +245,22 @@ fn apply_filter(filter_fn: fn(&str) -> String, input: &str) -> String { }) } -pub fn run(filter_name: Option<&str>, passthrough: bool) -> Result<()> { +fn apply_toml_filter(filter: &CompiledFilter, input: &str) -> String { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + toml_filter::apply_filter(filter, input) + })) + .unwrap_or_else(|_| { + eprintln!("[rtk] warning: filter panicked — passing through raw output"); + input.to_string() + }) +} + +/// Apply a filter to stdin and print the result. +/// +/// - `toml_path`: when set, compile that TOML file (no trust gate) and apply. +/// `filter_name` then selects among filters in the file. +/// - otherwise: existing `-f` Rust filter / auto-detect behavior. +pub fn run(filter_name: Option<&str>, passthrough: bool, toml_path: Option<&Path>) -> Result<()> { if passthrough { std::io::copy(&mut std::io::stdin(), &mut std::io::stdout()) .map_err(|e| anyhow::anyhow!("Failed to relay stdin: {}", e))?; @@ -259,20 +276,29 @@ 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 output = if let Some(path) = toml_path { + let filters = + toml_filter::compile_filters_from_path(path).map_err(|e| anyhow::anyhow!(e))?; + let filter = + toml_filter::select_compiled_filter(&filters, filter_name) + .map_err(|e| anyhow::anyhow!(e))?; + apply_toml_filter(filter, &buf) + } else { + 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), + }; + apply_rust_filter(filter_fn, &buf) }; - let output = apply_filter(filter_fn, &buf); let shown = never_worse(&buf, &output); print!("{}", shown); Ok(()) @@ -548,7 +574,7 @@ mod tests { panic!("filter bug"); } let input = "some output\n"; - let result = super::apply_filter(panicking_filter, input); + let result = super::apply_rust_filter(panicking_filter, input); assert_eq!(result, input); } diff --git a/src/core/toml_filter.rs b/src/core/toml_filter.rs index 977e8d974b..ea67777fe5 100644 --- a/src/core/toml_filter.rs +++ b/src/core/toml_filter.rs @@ -223,24 +223,91 @@ impl TomlFilterRegistry { } fn parse_and_compile(content: &str, source: &str) -> Result, String> { - let file: TomlFilterFile = toml::from_str(content) - .map_err(|e| format!("TOML parse error in {}: {}", source, e))?; - - if file.schema_version != 1 { - return Err(format!( - "unsupported schema_version {} in {} (expected 1)", - file.schema_version, source - )); - } + // Soft-fail individual filters: registry load must not abort on one bad entry. + compile_filters_from_str_inner(content, source, /* hard_fail */ false) + } +} + +/// Compile filters from a TOML file without consulting the trust store. +/// +/// Intended for explicit `rtk pipe --toml ` draft preview. Does **not** +/// mark the file trusted and does not inject it into the agent rewrite path. +pub fn compile_filters_from_path(path: &std::path::Path) -> Result, String> { + let content = std::fs::read_to_string(path) + .map_err(|e| format!("failed to read {}: {}", path.display(), e))?; + compile_filters_from_str(&content, &path.display().to_string()) +} + +/// Compile filters from TOML text without consulting the trust store. +/// +/// Unlike registry load, a single filter compile error is a hard failure — draft +/// preview should not silently drop the filter the user is iterating on. +pub fn compile_filters_from_str( + content: &str, + source: &str, +) -> Result, String> { + compile_filters_from_str_inner(content, source, /* hard_fail */ true) +} + +fn compile_filters_from_str_inner( + content: &str, + source: &str, + hard_fail: bool, +) -> Result, String> { + let file: TomlFilterFile = + toml::from_str(content).map_err(|e| format!("TOML parse error in {}: {}", source, e))?; - let mut compiled = Vec::new(); - for (name, def) in file.filters { - match compile_filter(name.clone(), def) { - Ok(f) => compiled.push(f), - Err(e) => eprintln!("[rtk] warning: filter '{}' in {}: {}", name, source, e), + if file.schema_version != 1 { + return Err(format!( + "unsupported schema_version {} in {} (expected 1)", + file.schema_version, source + )); + } + + if hard_fail && file.filters.is_empty() { + return Err(format!("no [filters.*] entries in {}", source)); + } + + let mut compiled = Vec::new(); + for (name, def) in file.filters { + match compile_filter(name.clone(), def) { + Ok(f) => compiled.push(f), + Err(e) if hard_fail => { + return Err(format!("filter '{}' in {}: {}", name, source, e)); } + Err(e) => eprintln!("[rtk] warning: filter '{}' in {}: {}", name, source, e), } - Ok(compiled) + } + if hard_fail && compiled.is_empty() { + return Err(format!("no usable filters compiled from {}", source)); + } + Ok(compiled) +} + +/// Pick one filter from a compiled list. +/// +/// - `Some(name)` → that filter by name +/// - `None` with exactly one filter → that filter +/// - `None` with multiple → error listing available names +pub fn select_compiled_filter<'a>( + filters: &'a [CompiledFilter], + name: Option<&str>, +) -> Result<&'a CompiledFilter, String> { + let available = || { + let mut names: Vec<&str> = filters.iter().map(|f| f.name.as_str()).collect(); + names.sort_unstable(); + names.join(", ") + }; + match name { + Some(n) => filters + .iter() + .find(|f| f.name == n) + .ok_or_else(|| format!("filter '{}' not found. Available: {}", n, available())), + None if filters.len() == 1 => Ok(&filters[0]), + None => Err(format!( + "multiple filters in file; pass -f to select. Available: {}", + available() + )), } } @@ -1965,4 +2032,42 @@ expected = "output line 1\noutput line 2" ); assert_eq!(found.unwrap().name, "my-new-tool"); } + + #[test] + fn compile_filters_from_str_hard_fails_on_bad_regex() { + let toml = r#" +schema_version = 1 +[filters.bad] +match_command = "(unclosed" +"#; + let err = compile_filters_from_str(toml, "test").expect_err("bad regex"); + assert!(err.contains("bad"), "err={}", err); + } + + #[test] + fn compile_filters_from_str_rejects_empty_filters() { + let err = compile_filters_from_str("schema_version = 1\n", "test").expect_err("empty"); + assert!(err.contains("no [filters.*]"), "err={}", err); + } + + #[test] + fn select_compiled_filter_single_without_name() { + let filters = make_filters( + "schema_version = 1\n[filters.only]\nmatch_command = \"^only\"\nmax_lines = 1\n", + ); + let selected = select_compiled_filter(&filters, None).expect("single"); + assert_eq!(selected.name, "only"); + } + + #[test] + fn select_compiled_filter_requires_name_when_multiple() { + let filters = make_filters( + "schema_version = 1\n[filters.a]\nmatch_command = \"^a\"\n[filters.b]\nmatch_command = \"^b\"\n", + ); + let err = select_compiled_filter(&filters, None).expect_err("multi"); + assert!(err.contains("multiple filters"), "err={}", err); + + let selected = select_compiled_filter(&filters, Some("b")).expect("named"); + assert_eq!(selected.name, "b"); + } } diff --git a/src/main.rs b/src/main.rs index b29cf0769b..e97b64320e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -648,10 +648,16 @@ enum Commands { /// Read stdin, apply filter, print filtered output (Unix pipe mode) Pipe { - /// Filter name (cargo-test, pytest, phpunit, phpstan, pint, grep, find, git-log, etc.) + /// Filter name (cargo-test, pytest, phpunit, phpstan, pint, grep, find, git-log, etc.). + /// With `--toml`, selects among filters defined in that file. #[arg(short, long)] filter: Option, + /// Compile and apply filters from a TOML file (draft preview; no trust gate). + /// Does not write the path into the trust store. + #[arg(long, value_name = "PATH", conflicts_with = "passthrough")] + toml: Option, + /// Pass stdin through without filtering #[arg(long)] passthrough: bool, @@ -2484,9 +2490,10 @@ fn run_cli() -> Result { Commands::Pipe { filter, + toml, passthrough, } => { - pipe_cmd::run(filter.as_deref(), passthrough)?; + pipe_cmd::run(filter.as_deref(), passthrough, toml.as_deref())?; 0 } diff --git a/tests/pipe_toml_test.rs b/tests/pipe_toml_test.rs new file mode 100644 index 0000000000..5f134efaff --- /dev/null +++ b/tests/pipe_toml_test.rs @@ -0,0 +1,155 @@ +//! `rtk pipe --toml` — apply an arbitrary (untrusted) TOML filter file to stdin. +#![cfg(unix)] + +use std::io::Write; +use std::process::{Command, Output, Stdio}; + +fn run_pipe_toml(toml_path: &std::path::Path, args: &[&str], input: &str) -> Output { + let mut cmd_args = vec!["pipe", "--toml"]; + let path = toml_path.to_str().expect("utf-8 path"); + cmd_args.push(path); + cmd_args.extend_from_slice(args); + + let mut child = Command::new(env!("CARGO_BIN_EXE_rtk")) + .args(&cmd_args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn rtk pipe"); + + child + .stdin + .take() + .expect("piped stdin") + .write_all(input.as_bytes()) + .expect("write stdin"); + + child.wait_with_output().expect("wait for rtk pipe") +} + +fn write_toml(dir: &tempfile::TempDir, name: &str, contents: &str) -> std::path::PathBuf { + let path = dir.path().join(name); + std::fs::write(&path, contents).expect("write toml"); + path +} + +#[test] +fn pipe_toml_applies_single_filter_without_trust() { + let dir = tempfile::tempdir().expect("tempdir"); + let toml = write_toml( + &dir, + "draft.toml", + r#" +schema_version = 1 +[filters.draft-noise] +description = "strip NOISE lines" +match_command = "^draft-noise\\b" +strip_lines_matching = ["^NOISE"] +"#, + ); + + let out = run_pipe_toml(&toml, &[], "keep me\nNOISE drop me\nkeep me too\n"); + + assert!( + out.status.success(), + "stderr={}", + String::from_utf8_lossy(&out.stderr) + ); + let stdout = String::from_utf8_lossy(&out.stdout); + // TOML pipeline joins lines with '\n' and does not re-add a trailing newline. + assert_eq!(stdout, "keep me\nkeep me too"); +} + +#[test] +fn pipe_toml_selects_named_filter_with_dash_f() { + let dir = tempfile::tempdir().expect("tempdir"); + let toml = write_toml( + &dir, + "multi.toml", + r#" +schema_version = 1 +[filters.keep-errors] +match_command = "^keep-errors\\b" +keep_lines_matching = ["(?i)error"] +[filters.strip-noise] +match_command = "^strip-noise\\b" +strip_lines_matching = ["^noise"] +"#, + ); + + let out = run_pipe_toml( + &toml, + &["-f", "keep-errors"], + "info ok\nERROR boom\nwarn meh\n", + ); + + assert!( + out.status.success(), + "stderr={}", + String::from_utf8_lossy(&out.stderr) + ); + assert_eq!(String::from_utf8_lossy(&out.stdout), "ERROR boom"); +} + +#[test] +fn pipe_toml_requires_dash_f_when_multiple_filters() { + let dir = tempfile::tempdir().expect("tempdir"); + let toml = write_toml( + &dir, + "multi.toml", + r#" +schema_version = 1 +[filters.a] +match_command = "^a\\b" +max_lines = 1 +[filters.b] +match_command = "^b\\b" +max_lines = 1 +"#, + ); + + let out = run_pipe_toml(&toml, &[], "line\n"); + assert!(!out.status.success()); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("multiple filters") && stderr.contains("-f"), + "stderr={}", + stderr + ); +} + +#[test] +fn pipe_toml_rejects_missing_file() { + let dir = tempfile::tempdir().expect("tempdir"); + let missing = dir.path().join("nope.toml"); + let out = run_pipe_toml(&missing, &[], "x\n"); + assert!(!out.status.success()); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("failed to read") || stderr.contains("No such file"), + "stderr={}", + stderr + ); +} + +#[test] +fn pipe_toml_does_not_require_trust_store_entry() { + // Same as the single-filter case, but assert trusted_filters.json is untouched / + // not required: filter lives outside gated paths and still applies. + let dir = tempfile::tempdir().expect("tempdir"); + let toml = write_toml( + &dir, + "outside-config.toml", + r#" +schema_version = 1 +[filters.preview] +match_command = "^preview\\b" +strip_lines_matching = ["^zzz"] +"#, + ); + + let out = run_pipe_toml(&toml, &[], "hello\nzzz gone\nworld\n"); + assert!(out.status.success()); + assert_eq!(String::from_utf8_lossy(&out.stdout), "hello\nworld"); +}