Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 41 additions & 15 deletions src/cmds/system/pipe_cmd.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -235,15 +237,30 @@ 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");
input.to_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))?;
Expand All @@ -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(())
Expand Down Expand Up @@ -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);
}

Expand Down
135 changes: 120 additions & 15 deletions src/core/toml_filter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -223,24 +223,91 @@ impl TomlFilterRegistry {
}

fn parse_and_compile(content: &str, source: &str) -> Result<Vec<CompiledFilter>, 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 <path>` 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<Vec<CompiledFilter>, 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<Vec<CompiledFilter>, 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<Vec<CompiledFilter>, 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 <name> to select. Available: {}",
available()
)),
}
}

Expand Down Expand Up @@ -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");
}
}
11 changes: 9 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,

/// 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<PathBuf>,

/// Pass stdin through without filtering
#[arg(long)]
passthrough: bool,
Expand Down Expand Up @@ -2484,9 +2490,10 @@ fn run_cli() -> Result<i32> {

Commands::Pipe {
filter,
toml,
passthrough,
} => {
pipe_cmd::run(filter.as_deref(), passthrough)?;
pipe_cmd::run(filter.as_deref(), passthrough, toml.as_deref())?;
0
}

Expand Down
Loading