From d02ea0d446154c4086df95c7d5aecc94eaa112b5 Mon Sep 17 00:00:00 2001 From: Wladi Mitzel Date: Wed, 15 Apr 2026 18:20:01 +0200 Subject: [PATCH 01/50] feat(pulumi): add CLI filters for preview/up/destroy/refresh/stack Adds 5 TOML filters for the Pulumi CLI, matching the existing OpenTofu/ Ansible/Terraform TOML-based pattern. Filters strip known noise (header banners, View Live URLs, policy pack loading, @ progress spinners, intermediate creating/updating/deleting rows, Duration, Node.js stack trace frames) while preserving resource change rows, policy violations, diagnostics, outputs, and summaries. Measured savings on a real neo-migration AWS project (~30 resources, Pulumi Cloud backend): pulumi up 65.8%, pulumi destroy 72.3%, preview --refresh 40.7%, refresh 34.9%, stack 29.3%. Lower savings on already- compact output (clean preview ~9%) because remaining content is signal per the correctness-over-compression design philosophy. Registers matching RtkRule in src/discover/rules.rs so the rewrite hook transparently proxies `pulumi ` through `rtk pulumi`. Bumps BUILTIN_TOML filter count 59 -> 64 and updates the expected-filter list and concat-discoverability test. Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 4 ++ README.md | 9 +++++ src/core/toml_filter.rs | 15 +++++--- src/discover/rules.rs | 9 +++++ src/filters/pulumi-destroy.toml | 62 +++++++++++++++++++++++++++++++ src/filters/pulumi-preview.toml | 61 ++++++++++++++++++++++++++++++ src/filters/pulumi-refresh.toml | 59 +++++++++++++++++++++++++++++ src/filters/pulumi-stack.toml | 47 +++++++++++++++++++++++ src/filters/pulumi-up.toml | 66 +++++++++++++++++++++++++++++++++ 9 files changed, 327 insertions(+), 5 deletions(-) create mode 100644 src/filters/pulumi-destroy.toml create mode 100644 src/filters/pulumi-preview.toml create mode 100644 src/filters/pulumi-refresh.toml create mode 100644 src/filters/pulumi-stack.toml create mode 100644 src/filters/pulumi-up.toml diff --git a/CHANGELOG.md b/CHANGELOG.md index a453097765..47b3ca201b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Features + +* **pulumi:** add CLI filters for `preview`, `up`, `destroy`, `refresh`, `stack` (TOML-based, ~70% savings) + ### Bug Fixes * **git:** remove `-u` short alias from `--ultra-compact` to fix `git push -u` upstream tracking ([#1086](https://github.com/rtk-ai/rtk/issues/1086)) diff --git a/README.md b/README.md index 6228a689d0..cfa59c0728 100644 --- a/README.md +++ b/README.md @@ -229,6 +229,15 @@ rtk kubectl logs # Deduplicated logs rtk kubectl services # Compact service list ``` +### Infrastructure as Code +```bash +rtk pulumi preview # Strip header/URL/duration noise +rtk pulumi up # Compact apply output +rtk pulumi destroy # Compact destroy output +rtk pulumi refresh # Drift summary +rtk pulumi stack # Stack metadata (strips owner/timestamps) +``` + ### Data & Analytics ```bash rtk json config.json # Structure without values diff --git a/src/core/toml_filter.rs b/src/core/toml_filter.rs index 06060d22df..b1a360a814 100644 --- a/src/core/toml_filter.rs +++ b/src/core/toml_filter.rs @@ -1588,6 +1588,11 @@ match_command = "^make\\b" "poetry-install", "pre-commit", "ps", + "pulumi-destroy", + "pulumi-preview", + "pulumi-refresh", + "pulumi-stack", + "pulumi-up", "quarto-render", "rsync", "shellcheck", @@ -1621,8 +1626,8 @@ match_command = "^make\\b" let filters = make_filters(BUILTIN_TOML); assert_eq!( filters.len(), - 59, - "Expected exactly 59 built-in filters, got {}. \ + 64, + "Expected exactly 64 built-in filters, got {}. \ Update this count when adding/removing filters in src/filters/.", filters.len() ); @@ -1679,11 +1684,11 @@ expected = "output line 1\noutput line 2" let combined = format!("{}\n\n{}", BUILTIN_TOML, new_filter); let filters = make_filters(&combined); - // All 59 existing filters still present + 1 new = 60 + // All 64 existing filters still present + 1 new = 65 assert_eq!( filters.len(), - 60, - "Expected 60 filters after concat (59 built-in + 1 new)" + 65, + "Expected 65 filters after concat (64 built-in + 1 new)" ); // New filter is discoverable diff --git a/src/discover/rules.rs b/src/discover/rules.rs index 847ff7dcc8..ac0fa0abe3 100644 --- a/src/discover/rules.rs +++ b/src/discover/rules.rs @@ -725,6 +725,15 @@ pub const RULES: &[RtkRule] = &[ subcmd_savings: &[], subcmd_status: &[], }, + RtkRule { + pattern: r"^pulumi\s+(preview|up|destroy|refresh|stack)(\s|$)", + rtk_cmd: "rtk pulumi", + rewrite_prefixes: &["pulumi"], + category: "Infra", + savings_pct: 70.0, + subcmd_savings: &[], + subcmd_status: &[], + }, RtkRule { pattern: r"^quarto\s+render", rtk_cmd: "rtk quarto", diff --git a/src/filters/pulumi-destroy.toml b/src/filters/pulumi-destroy.toml new file mode 100644 index 0000000000..f2b7ac1000 --- /dev/null +++ b/src/filters/pulumi-destroy.toml @@ -0,0 +1,62 @@ +[filters.pulumi-destroy] +description = "Compact Pulumi destroy output" +match_command = "^pulumi\\s+destroy(\\s|$)" +strip_ansi = true +strip_lines_matching = [ + "^\\s*$", + "^Destroying \\(", + "^Previewing (update|destroy)", + "^View in Browser", + "^View Live:", + "^Duration:", + "^Permalink:", + "^\\s*Type\\s+Name\\s+", + "^Loading policy packs", + "^@ (Previewing (update|destroy)|Destroying)", + "^More information at:", + "^Use `pulumi ", + "^The resources in the stack have been deleted", + "^If you want to remove the stack completely", + "^\\s+-\\s+.*\\bdeleting\\s+\\(", + "^\\s{4,}at\\s+\\S+\\s*\\(", + "^\\s{4,}at\\s+/", + "^\\s+at\\s+processTicksAndRejections", + "^\\s+promise:\\s+Promise", + "^\\s+\\[Circular", + "^\\s*\\}\\s*$", + "^\\s* Date: Sat, 18 Apr 2026 22:44:51 +0200 Subject: [PATCH 02/50] fix(pulumi): address review feedback + JSON-safety - Revert CHANGELOG.md (release-please CICD generates from commit msg) - Narrow pulumi-stack match_command to explicit subcommand whitelist (bare stack, ls, output, history, select, init, rm, rename, tag, unselect, change-secrets-provider). Excludes export (JSON state) and graph (DOT), which now passthrough unfiltered. - Lower savings_pct 70 -> 45 (measured mean across 5 subcommands). Populate subcmd_savings with measured values: up=66, destroy=72, refresh=35, preview=25, stack=29. JSON-safety (self-found during review validation): - All 5 pulumi subcommands support -j/--json; Rust regex crate has no lookahead so match_command can't negatively exclude --json. - Remove max_lines from all 5 filters; strip patterns don't match JSON structure so JSON passes through unfiltered. - Remove ^\\s*\\}\\s*$ strip pattern (was targeting Node.js stack trace trailers, but also matched legitimate JSON closing braces). Verified on neo-migration stack: - pulumi stack ls/output/history/export, preview --json, stack graph: byte-identical with RTK_NO_TOML=1 baseline (filter transparent). - Human-readable preview still compresses (~20% on clean state). - 10/10 inline pulumi tests pass; 1449/0 full cargo test suite. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 4 ---- src/discover/rules.rs | 10 ++++++++-- src/filters/pulumi-destroy.toml | 2 -- src/filters/pulumi-preview.toml | 2 -- src/filters/pulumi-refresh.toml | 2 -- src/filters/pulumi-stack.toml | 3 +-- src/filters/pulumi-up.toml | 2 -- 7 files changed, 9 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47b3ca201b..a453097765 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Features - -* **pulumi:** add CLI filters for `preview`, `up`, `destroy`, `refresh`, `stack` (TOML-based, ~70% savings) - ### Bug Fixes * **git:** remove `-u` short alias from `--ultra-compact` to fix `git push -u` upstream tracking ([#1086](https://github.com/rtk-ai/rtk/issues/1086)) diff --git a/src/discover/rules.rs b/src/discover/rules.rs index ac0fa0abe3..c85c108cf4 100644 --- a/src/discover/rules.rs +++ b/src/discover/rules.rs @@ -730,8 +730,14 @@ pub const RULES: &[RtkRule] = &[ rtk_cmd: "rtk pulumi", rewrite_prefixes: &["pulumi"], category: "Infra", - savings_pct: 70.0, - subcmd_savings: &[], + savings_pct: 45.0, + subcmd_savings: &[ + ("up", 66.0), + ("destroy", 72.0), + ("refresh", 35.0), + ("preview", 25.0), + ("stack", 29.0), + ], subcmd_status: &[], }, RtkRule { diff --git a/src/filters/pulumi-destroy.toml b/src/filters/pulumi-destroy.toml index f2b7ac1000..1052c0ff66 100644 --- a/src/filters/pulumi-destroy.toml +++ b/src/filters/pulumi-destroy.toml @@ -23,10 +23,8 @@ strip_lines_matching = [ "^\\s+at\\s+processTicksAndRejections", "^\\s+promise:\\s+Promise", "^\\s+\\[Circular", - "^\\s*\\}\\s*$", "^\\s* Date: Sat, 6 Jun 2026 10:42:53 +0800 Subject: [PATCH 03/50] fix(vitest): preserve explicit reporters --- src/cmds/js/vitest_cmd.rs | 177 ++++++++++++++++++++++++++++++-------- 1 file changed, 142 insertions(+), 35 deletions(-) diff --git a/src/cmds/js/vitest_cmd.rs b/src/cmds/js/vitest_cmd.rs index fc80bff696..233eaf39cf 100644 --- a/src/cmds/js/vitest_cmd.rs +++ b/src/cmds/js/vitest_cmd.rs @@ -206,16 +206,15 @@ fn extract_failures_regex(output: &str) -> Vec { pub fn run_test(command: &Commands, args: &[String], verbose: u8) -> Result { let timer = tracking::TimedExecution::start(); + let mut passthrough_requested = false; let (framework, mut cmd) = match command { Commands::Vitest { .. } => { let framework = "vitest"; let mut cmd = package_manager_exec(framework); - cmd - // Force non-watch mode - .arg("run") - // Enable JSON structured output - .arg("--reporter=json"); + let effective_args = build_vitest_effective_args(args); + passthrough_requested = effective_args.passthrough; + cmd.args(effective_args.args); (framework, cmd) } Commands::Jest { .. } => { @@ -231,42 +230,23 @@ pub fn run_test(command: &Commands, args: &[String], verbose: u8) -> Result _ => unreachable!(), }; - for arg in args { - if arg == "run" - || arg.starts_with("--json") - || arg.starts_with("--reporter") - || arg.starts_with("--watch") - { - continue; + if !matches!(command, Commands::Vitest { .. }) { + for arg in args { + if arg == "run" + || arg.starts_with("--json") + || arg.starts_with("--reporter") + || arg.starts_with("--watch") + { + continue; + } + cmd.arg(arg); } - cmd.arg(arg); } let result = exec_capture(&mut cmd).context(format!("Failed to run {}", framework))?; let combined = result.combined(); - // Parse output using VitestParser - let parse_result = VitestParser::parse(&result.stdout); - let mode = FormatMode::from_verbosity(verbose); - - let filtered = match parse_result { - ParseResult::Full(data) => { - if verbose > 0 { - eprintln!("{} run (Tier 1: Full JSON parse)", framework); - } - data.format(mode) - } - ParseResult::Degraded(data, warnings) => { - if verbose > 0 { - emit_degradation_warning(framework, &warnings.join(", ")); - } - data.format(mode) - } - ParseResult::Passthrough(raw) => { - emit_passthrough_warning(framework, "All parsing tiers failed"); - raw - } - }; + let filtered = format_test_output(framework, &result.stdout, &combined, passthrough_requested, verbose); if let Some(hint) = crate::core::tee::tee_and_hint(&combined, format!("{}_run", framework).as_str(), result.exit_code) @@ -289,10 +269,84 @@ pub fn run_test(command: &Commands, args: &[String], verbose: u8) -> Result Ok(0) } +struct EffectiveVitestArgs { + args: Vec, + passthrough: bool, +} + +fn build_vitest_effective_args(args: &[String]) -> EffectiveVitestArgs { + let passthrough = has_explicit_vitest_reporter(args); + let mut effective = vec!["run".to_string()]; + + if !passthrough { + effective.push("--reporter=json".to_string()); + } + + for arg in args { + if should_skip_vitest_arg(arg) { + continue; + } + effective.push(arg.clone()); + } + + EffectiveVitestArgs { + args: effective, + passthrough, + } +} + +fn has_explicit_vitest_reporter(args: &[String]) -> bool { + args.iter() + .any(|arg| arg == "--reporter" || arg.starts_with("--reporter=")) +} + +fn should_skip_vitest_arg(arg: &str) -> bool { + arg == "run" || arg.starts_with("--json") || arg.starts_with("--watch") +} + +fn format_test_output( + framework: &str, + stdout: &str, + combined: &str, + passthrough_requested: bool, + verbose: u8, +) -> String { + if passthrough_requested { + return truncate_passthrough(combined); + } + + let parse_result = VitestParser::parse(stdout); + let mode = FormatMode::from_verbosity(verbose); + let filtered = match parse_result { + ParseResult::Full(data) => { + if verbose > 0 { + eprintln!("{} run (Tier 1: Full JSON parse)", framework); + } + data.format(mode) + } + ParseResult::Degraded(data, warnings) => { + if verbose > 0 { + emit_degradation_warning(framework, &warnings.join(", ")); + } + data.format(mode) + } + ParseResult::Passthrough(raw) => { + emit_passthrough_warning(framework, "All parsing tiers failed"); + raw + } + }; + + filtered +} + #[cfg(test)] mod tests { use super::*; + fn args(values: &[&str]) -> Vec { + values.iter().map(|value| value.to_string()).collect() + } + #[test] fn test_vitest_parser_json() { let json = r#"{ @@ -397,4 +451,57 @@ Scope: all 6 workspace projects assert_eq!(data.total, 2); assert_eq!(data.passed, 2); } + + #[test] + fn test_vitest_effective_args_inject_json_reporter_by_default() { + let effective = build_vitest_effective_args(&args(&["run", "constants.test.ts", "--watch"])); + + assert!(!effective.passthrough); + assert_eq!( + effective.args, + args(&["run", "--reporter=json", "constants.test.ts"]) + ); + } + + #[test] + fn test_vitest_effective_args_preserve_explicit_reporter_equals() { + let effective = + build_vitest_effective_args(&args(&["constants.test.ts", "--reporter=verbose"])); + + assert!(effective.passthrough); + assert_eq!( + effective.args, + args(&["run", "constants.test.ts", "--reporter=verbose"]) + ); + } + + #[test] + fn test_vitest_effective_args_preserve_explicit_reporter_value() { + let effective = + build_vitest_effective_args(&args(&["run", "constants.test.ts", "--reporter", "verbose"])); + + assert!(effective.passthrough); + assert_eq!( + effective.args, + args(&["run", "constants.test.ts", "--reporter", "verbose"]) + ); + } + + #[test] + fn test_vitest_explicit_reporter_keeps_verbose_output() { + let output = r#" + ✓ constants/publicPaths.test.ts > public paths > keeps docs path + ✓ constants/publicPaths.test.ts > public paths > keeps app path + + Test Files 1 passed (1) + Tests 2 passed (2) + Duration 450ms +"#; + + let filtered = format_test_output("vitest", output, output, true, 0); + + assert!(filtered.contains("keeps docs path")); + assert!(filtered.contains("keeps app path")); + assert!(filtered.contains("Tests 2 passed")); + } } From 84616d131d77aa853b386b4fdb4a45b52b0c78aa Mon Sep 17 00:00:00 2001 From: Nicolas Le Cam Date: Mon, 8 Jun 2026 18:49:18 +0200 Subject: [PATCH 04/50] fix(grep): stabilize argument parsing --- docs/contributing/ARCHITECTURE.md | 4 +- src/cmds/system/grep_cmd.rs | 151 ++++++++++++++++++++++++++---- src/main.rs | 19 +--- 3 files changed, 140 insertions(+), 34 deletions(-) diff --git a/docs/contributing/ARCHITECTURE.md b/docs/contributing/ARCHITECTURE.md index 72d2887666..108e58fdc3 100644 --- a/docs/contributing/ARCHITECTURE.md +++ b/docs/contributing/ARCHITECTURE.md @@ -746,7 +746,7 @@ Single-threaded execution with `Mutex>` for future-proofing. No └────────────────────────────────────────────────────────────────────────┘ main.rs:47-49 -#[arg(short, long, action = clap::ArgAction::Count, global = true)] +#[arg(short, long, action = clap::ArgAction::Count)] verbose: u8, Levels: @@ -773,7 +773,7 @@ if verbose > 0 { └────────────────────────────────────────────────────────────────────────┘ main.rs:51-53 -#[arg(short = 'u', long, global = true)] +#[arg(long, global = true)] ultra_compact: bool, Features: diff --git a/src/cmds/system/grep_cmd.rs b/src/cmds/system/grep_cmd.rs index 3f7d9a3276..5ca17cef4f 100644 --- a/src/cmds/system/grep_cmd.rs +++ b/src/cmds/system/grep_cmd.rs @@ -1,26 +1,94 @@ //! Filters grep output by grouping matches by file. -use crate::core::config; use crate::core::stream::exec_capture; use crate::core::tracking; use crate::core::utils::resolved_command; +use crate::core::{args_utils, config}; use anyhow::{Context, Result}; use regex::Regex; use std::collections::HashMap; +/// Extracts (pattern, path, flags) from the raw trailing args. +/// +/// Scans for the first and second non-flag positional arguments. Everything +/// after `--` is also considered positional. Flags (args starting with `-`) +/// are collected as pass-through args for rg. +fn extract_pattern_path(args: &[String]) -> (Option, String, Vec) { + let mut positionals: Vec = Vec::new(); + let mut flags: Vec = Vec::new(); + let mut past_dashdash = false; + + for arg in args { + if arg == "--" { + past_dashdash = true; + continue; + } + if past_dashdash || !arg.starts_with('-') { + positionals.push(arg.clone()); + } else { + flags.push(arg.clone()); + } + } + + let pattern = positionals.first().cloned(); + let path = positionals + .get(1) + .cloned() + .unwrap_or_else(|| ".".to_string()); + + // Any extra positionals beyond pattern+path go back into flags for rg. + for extra in positionals.iter().skip(2) { + flags.push(extra.clone()); + } + + (pattern, path, flags) +} + #[allow(clippy::too_many_arguments)] pub fn run( - pattern: &str, - path: &str, max_line_len: usize, max_results: usize, context_only: bool, file_type: Option<&str>, - extra_args: &[String], + args: &[String], verbose: u8, ) -> Result { let timer = tracking::TimedExecution::start(); + // --version / --help: pass through to rg without filtering. + // Note: Clap strips `--` before populating trailing_var_arg, so both + // `rtk grep --version` and `rtk grep -- --version` land here identically. + if args + .iter() + .any(|a| a == "--version" || a == "--help" || a == "-h") + { + let mut rg_cmd = resolved_command("rg"); + rg_cmd.args(args); + let result = exec_capture(&mut rg_cmd) + .or_else(|_| { + // rg unavailable: fall back to system grep. + let mut grep_cmd = resolved_command("grep"); + grep_cmd.args(args); + exec_capture(&mut grep_cmd) + }) + .context("grep/rg failed")?; + print!("{}", result.stdout); + if !result.stderr.is_empty() { + eprint!("{}", result.stderr); + } + return Ok(result.exit_code); + } + + // Re-insert `--` when clap's trailing_var_arg consumed it + let args = args_utils::restore_double_dash(args); + + let (pattern_opt, path, extra_args) = extract_pattern_path(&args); + + let Some(pattern) = pattern_opt else { + eprintln!("rtk grep: pattern required"); + return Ok(1); + }; + if verbose > 0 { eprintln!("grep: '{}' in {}", pattern, path); } @@ -36,13 +104,13 @@ pub fn run( // -H: always emit the filename. // -0: NUL-separate filename. Allows the parser to disambiguate filenames or // content containing `:digits:` patterns (issue #1436). - rg_cmd.args(["-nH0", "--no-heading", "--no-ignore-vcs", &rg_pattern, path]); + rg_cmd.args(["-nH0", "--no-heading", "--no-ignore-vcs"]); if let Some(ft) = file_type { rg_cmd.arg("--type").arg(ft); } - for arg in extra_args { + for arg in &extra_args { // Fix: skip grep-ism -r flag (rg is recursive by default; rg -r means --replace) if arg == "-r" || arg == "--recursive" { continue; @@ -50,17 +118,25 @@ pub fn run( rg_cmd.arg(arg); } + // `--` after all flags, before pattern + path: prevents rg from interpreting + // patterns starting with `-` (e.g. `--version`) as its own flags. + rg_cmd.args(["--", &rg_pattern, &path]); + let result = exec_capture(&mut rg_cmd) .or_else(|_| { + // rg unavailable: fall back to system grep with the original, + // untranslated pattern (grep interprets BRE natively). + // `--` before pattern keeps grep from misreading flag-like patterns. let mut grep_cmd = resolved_command("grep"); - // When we fall back to grep, include all args, not just -rnHZ. - grep_cmd.args(["-rnHZ", pattern, path]).args(extra_args); + grep_cmd + .args(&extra_args) + .args(["-rnHZ", "--", &pattern, &path]); exec_capture(&mut grep_cmd) }) .context("grep/rg failed")?; // Passthrough output flags that produce output that is already small. - if has_format_flag(extra_args) { + if has_format_flag(&extra_args) { print!("{}", result.stdout); if !result.stderr.is_empty() { eprint!("{}", result.stderr.trim()); @@ -98,13 +174,8 @@ pub fn run( return Ok(exit_code); } - // Always filter: truncate long lines, apply per-file and global caps. - // Output in standard file:line:content format that AI agents can parse. - // (A passthrough approach yields 0% savings — no reason for RTK to exist on that path.) - let total_matches = result.stdout.lines().count(); - let context_re = if context_only { - Regex::new(&format!("(?i).{{0,20}}{}.*", regex::escape(pattern))).ok() + Regex::new(&format!("(?i).{{0,20}}{}.*", regex::escape(&pattern))).ok() } else { None }; @@ -114,10 +185,13 @@ pub fn run( let Some((file, line_num, content)) = parse_match_line(line) else { continue; }; - let cleaned = clean_line(content, max_line_len, context_re.as_ref(), pattern); + let cleaned = clean_line(content, max_line_len, context_re.as_ref(), &pattern); by_file.entry(file).or_default().push((line_num, cleaned)); } + // Derive total from parsed results so the header matches what we show. + let total_matches: usize = by_file.values().map(|v| v.len()).sum(); + let mut rtk_output = String::new(); rtk_output.push_str(&format!( "{} matches in {} files:\n\n", @@ -326,6 +400,51 @@ mod tests { assert_eq!(filtered[0], "-i"); } + // --- extract_pattern_path --- + + #[test] + fn test_extract_pattern_path_simple() { + let args = vec!["foo".to_string(), "src/".to_string()]; + let (pat, path, flags) = extract_pattern_path(&args); + assert_eq!(pat.as_deref(), Some("foo")); + assert_eq!(path, "src/"); + assert!(flags.is_empty()); + } + + #[test] + fn test_extract_pattern_path_with_flags() { + let args = vec!["-i".to_string(), "foo".to_string(), "src/".to_string()]; + let (pat, path, flags) = extract_pattern_path(&args); + assert_eq!(pat.as_deref(), Some("foo")); + assert_eq!(path, "src/"); + assert_eq!(flags, vec!["-i"]); + } + + #[test] + fn test_extract_pattern_path_default_path() { + let args = vec!["foo".to_string()]; + let (pat, path, _flags) = extract_pattern_path(&args); + assert_eq!(pat.as_deref(), Some("foo")); + assert_eq!(path, "."); + } + + #[test] + fn test_extract_pattern_path_no_args() { + let (pat, path, _flags) = extract_pattern_path(&[]); + assert!(pat.is_none()); + assert_eq!(path, "."); + } + + #[test] + fn test_extract_pattern_path_dashdash() { + // After --, args are positional even if they look like flags + let args = vec!["--".to_string(), "--version".to_string()]; + let (pat, path, flags) = extract_pattern_path(&args); + assert_eq!(pat.as_deref(), Some("--version")); + assert_eq!(path, "."); + assert!(flags.is_empty()); + } + // --- truncation accuracy --- #[test] diff --git a/src/main.rs b/src/main.rs index a5bde544a3..6d21faf5a2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -62,8 +62,8 @@ struct Cli { #[command(subcommand)] command: Commands, - /// Verbosity level (-v, -vv, -vvv) - #[arg(short, long, action = clap::ArgAction::Count, global = true)] + /// Verbosity level (-v, -vv, -vvv) — only recognized before the subcommand + #[arg(short, long, action = clap::ArgAction::Count)] verbose: u8, /// Ultra-compact mode: ASCII icons, inline format (Level 2 optimizations) @@ -302,11 +302,6 @@ enum Commands { /// Compact grep - strips whitespace, truncates, groups by file Grep { - /// Pattern to search - pattern: String, - /// Path to search in - #[arg(default_value = ".")] - path: String, /// Max line length #[arg(short = 'l', long, default_value = "80")] max_len: usize, @@ -319,10 +314,7 @@ enum Commands { /// Filter by file type (e.g., ts, py, rust) #[arg(short = 't', long)] file_type: Option, - /// Show line numbers (always on, accepted for grep/rg compatibility) - #[arg(short = 'n', long)] - line_numbers: bool, - /// Extra ripgrep arguments (e.g., -i, -A 3, -w, --glob) + /// Pattern, path, and any grep/rg flags (e.g. -v, -i, -A 3, --glob, --version) #[arg(trailing_var_arg = true, allow_hyphen_values = true)] extra_args: Vec, }, @@ -1800,17 +1792,12 @@ fn run_cli() -> Result { } Commands::Grep { - pattern, - path, max_len, max, context_only, file_type, - line_numbers: _, // no-op: line numbers always enabled in grep_cmd::run extra_args, } => grep_cmd::run( - &pattern, - &path, max_len, max, context_only, From c40b3ef9dbefeecff0408b0fd459b8376aeabdc1 Mon Sep 17 00:00:00 2001 From: Nicolas Le Cam Date: Thu, 11 Jun 2026 00:16:57 +0200 Subject: [PATCH 05/50] fix(grep): fix value-taking flags, -rN clusters, multi-path, and -e patterns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four bugs in `extract_pattern_path` caused `rtk grep` to misparse common invocations: - `-A 2 error src` → `2` was taken as the pattern (value-taking flags not recognised) - `-rn foo src` → `n` leaked to rg as `--replace` value (only standalone `-r` was stripped, not combined clusters like `-rn`) - `TODO src tests` → second path silently dropped (only one path kept) - `-i x agent -g '*.md'` → rg regex error (`*.md` became positional before `-g` could consume it) New implementation: - `VALUE_FLAGS_SHORT`: explicit byte-set of 2-char flags that consume one following token (`A`, `B`, `C`, `g`, `f`, `j`, `m`) - `process_flag()`: strips `r`/`R` from any single-dash cluster, drops `--recursive`; replaces the old inline `if arg == "-r"` loop - `extract_pattern_path` returns `(Vec, Vec, Vec)` (patterns, paths, flags) — no more `Option` for pattern - `-e` values collected separately; if any `-e` is present, all positionals are paths; otherwise first positional is the pattern - All patterns forwarded to rg as `-e` flags (BRE `\|`→`|` inline) Co-Authored-By: Claude Sonnet 4.6 --- src/cmds/system/grep_cmd.rs | 322 ++++++++++++++++++++++++++---------- 1 file changed, 232 insertions(+), 90 deletions(-) diff --git a/src/cmds/system/grep_cmd.rs b/src/cmds/system/grep_cmd.rs index 5ca17cef4f..9209374fee 100644 --- a/src/cmds/system/grep_cmd.rs +++ b/src/cmds/system/grep_cmd.rs @@ -8,40 +8,116 @@ use anyhow::{Context, Result}; use regex::Regex; use std::collections::HashMap; -/// Extracts (pattern, path, flags) from the raw trailing args. +/// Short flags (exact 2-char form `-X`) that consume one following token as their value. +/// `-e` is handled separately — its value goes into `patterns`, not `flags`. +/// Deliberately small: unknown flags pass through unchanged. The failure mode +/// for a missing entry is a visible wrong result, not a silent corruption. +const VALUE_FLAGS_SHORT: &[u8] = b"ABCgfjm"; + +/// Normalise a flag arg for rg forwarding. +/// Returns `None` when the flag should be dropped (grep-ism recursive flags). +fn process_flag(arg: &str) -> Option { + if arg == "--recursive" { + return None; + } + // Long flags pass through unchanged. + if arg.starts_with("--") { + return Some(arg.to_string()); + } + // Short clusters: strip `r`/`R` (grep recursive; rg -r means --replace). + if let Some(rest) = arg.strip_prefix('-') { + let cleaned: String = rest.chars().filter(|&c| c != 'r' && c != 'R').collect(); + return if cleaned.is_empty() { + None + } else { + Some(format!("-{}", cleaned)) + }; + } + Some(arg.to_string()) +} + +/// Extracts `(patterns, paths, flags)` from the raw trailing args. +/// +/// - `patterns`: first non-flag positional prepended to any `-e` values. +/// All patterns are passed to rg as `-e` flags, so positional and `-e` are +/// interchangeable from rg's perspective. Empty → caller should error. +/// - `paths`: all subsequent non-flag positionals. Empty → caller defaults to `["."]`. +/// - `flags`: other flags forwarded to rg (recursive flags already stripped). /// -/// Scans for the first and second non-flag positional arguments. Everything -/// after `--` is also considered positional. Flags (args starting with `-`) -/// are collected as pass-through args for rg. -fn extract_pattern_path(args: &[String]) -> (Option, String, Vec) { +/// Value-taking short flags (see `VALUE_FLAGS_SHORT`) consume the next token +/// as their value so it is not mistaken for the pattern. Combined clusters like +/// `-rn` have `r`/`R` stripped before forwarding. `--` marks everything after +/// it as positional even if flag-shaped. +fn extract_pattern_path(args: &[String]) -> (Vec, Vec, Vec) { + let mut e_patterns: Vec = Vec::new(); let mut positionals: Vec = Vec::new(); let mut flags: Vec = Vec::new(); let mut past_dashdash = false; + let mut i = 0; + + while i < args.len() { + let arg = &args[i]; + + if past_dashdash { + positionals.push(arg.clone()); + i += 1; + continue; + } - for arg in args { if arg == "--" { past_dashdash = true; + i += 1; continue; } - if past_dashdash || !arg.starts_with('-') { - positionals.push(arg.clone()); + + if arg.starts_with('-') { + // -e: consume value into patterns (not flags) + if arg == "-e" { + if i + 1 < args.len() { + e_patterns.push(args[i + 1].clone()); + i += 2; + } else { + i += 1; + } + continue; + } + + // Exact 2-char value-taking flag: consume next token as value + if arg.len() == 2 + && VALUE_FLAGS_SHORT.contains(&arg.as_bytes()[1]) + { + flags.push(arg.clone()); + if i + 1 < args.len() { + flags.push(args[i + 1].clone()); + i += 2; + } else { + i += 1; + } + continue; + } + + // Strip recursive flags; pass everything else through + if let Some(cleaned) = process_flag(arg) { + flags.push(cleaned); + } + i += 1; } else { - flags.push(arg.clone()); + positionals.push(arg.clone()); + i += 1; } } - let pattern = positionals.first().cloned(); - let path = positionals - .get(1) - .cloned() - .unwrap_or_else(|| ".".to_string()); - - // Any extra positionals beyond pattern+path go back into flags for rg. - for extra in positionals.iter().skip(2) { - flags.push(extra.clone()); - } + // If -e was used: all positionals are paths; -e values are the patterns. + // If -e was not used: first positional is the pattern, rest are paths. + let (patterns, paths) = if !e_patterns.is_empty() { + (e_patterns, positionals) + } else { + let paths = positionals.iter().skip(1).cloned().collect(); + let patterns = positionals.into_iter().take(1).collect(); + (patterns, paths) + }; - (pattern, path, flags) + (patterns, paths, flags) } #[allow(clippy::too_many_arguments)] @@ -82,20 +158,30 @@ pub fn run( // Re-insert `--` when clap's trailing_var_arg consumed it let args = args_utils::restore_double_dash(args); - let (pattern_opt, path, extra_args) = extract_pattern_path(&args); + let (patterns, paths, extra_args) = extract_pattern_path(&args); - let Some(pattern) = pattern_opt else { - eprintln!("rtk grep: pattern required"); + if patterns.is_empty() { + eprintln!("rtk grep: pattern required (positional or -e)"); return Ok(1); + } + + let display_pattern = if patterns.len() == 1 { + patterns[0].clone() + } else { + patterns.join("|") }; + let paths = if paths.is_empty() { + vec![".".to_string()] + } else { + paths + }; + let path_display = paths.join(" "); + if verbose > 0 { - eprintln!("grep: '{}' in {}", pattern, path); + eprintln!("grep: '{}' in {}", display_pattern, path_display); } - // Fix: convert BRE alternation \| → | for rg (which uses PCRE-style regex) - let rg_pattern = pattern.replace(r"\|", "|"); - let mut rg_cmd = resolved_command("rg"); // --no-ignore-vcs: match grep -r behavior (don't skip .gitignore'd files). // Without this, rg returns 0 matches for files in .gitignore, causing @@ -110,27 +196,31 @@ pub fn run( rg_cmd.arg("--type").arg(ft); } - for arg in &extra_args { - // Fix: skip grep-ism -r flag (rg is recursive by default; rg -r means --replace) - if arg == "-r" || arg == "--recursive" { - continue; - } - rg_cmd.arg(arg); + // extra_args is already stripped of -r/-R/-recursive by extract_pattern_path + rg_cmd.args(&extra_args); + + // All patterns as -e flags (BRE \| → | translation for rg's PCRE engine). + // Using -e keeps `--` semantically as a flag/path separator, not part of the pattern. + for p in &patterns { + rg_cmd.args(["-e", &p.replace(r"\|", "|")]); } - // `--` after all flags, before pattern + path: prevents rg from interpreting - // patterns starting with `-` (e.g. `--version`) as its own flags. - rg_cmd.args(["--", &rg_pattern, &path]); + // `--` after all flags: prevents rg from interpreting path args starting + // with `-` as its own flags. + rg_cmd.arg("--"); + rg_cmd.args(&paths); let result = exec_capture(&mut rg_cmd) .or_else(|_| { // rg unavailable: fall back to system grep with the original, - // untranslated pattern (grep interprets BRE natively). - // `--` before pattern keeps grep from misreading flag-like patterns. + // untranslated patterns (grep interprets BRE natively). let mut grep_cmd = resolved_command("grep"); - grep_cmd - .args(&extra_args) - .args(["-rnHZ", "--", &pattern, &path]); + grep_cmd.args(&extra_args); + for p in &patterns { + grep_cmd.args(["-e", p]); + } + grep_cmd.args(["-rnHZ", "--"]); + grep_cmd.args(&paths); exec_capture(&mut grep_cmd) }) .context("grep/rg failed")?; @@ -143,9 +233,9 @@ pub fn run( } let args_display = if extra_args.is_empty() { - format!("'{}' {}", pattern, path) + format!("'{}' {}", display_pattern, path_display) } else { - format!("{} '{}' {}", extra_args.join(" "), pattern, path) + format!("{} '{}' {}", extra_args.join(" "), display_pattern, path_display) }; timer.track_passthrough( @@ -163,10 +253,10 @@ pub fn run( if exit_code == 2 && !result.stderr.trim().is_empty() { eprintln!("{}", result.stderr.trim()); } - let msg = format!("0 matches for '{}'", pattern); + let msg = format!("0 matches for '{}'", display_pattern); println!("{}", msg); timer.track( - &format!("grep -rn '{}' {}", pattern, path), + &format!("grep -rn '{}' {}", display_pattern, path_display), "rtk grep", &raw_output, &msg, @@ -175,7 +265,11 @@ pub fn run( } let context_re = if context_only { - Regex::new(&format!("(?i).{{0,20}}{}.*", regex::escape(&pattern))).ok() + Regex::new(&format!( + "(?i).{{0,20}}{}.*", + regex::escape(&display_pattern) + )) + .ok() } else { None }; @@ -185,7 +279,7 @@ pub fn run( let Some((file, line_num, content)) = parse_match_line(line) else { continue; }; - let cleaned = clean_line(content, max_line_len, context_re.as_ref(), &pattern); + let cleaned = clean_line(content, max_line_len, context_re.as_ref(), &display_pattern); by_file.entry(file).or_default().push((line_num, cleaned)); } @@ -225,7 +319,7 @@ pub fn run( print!("{}", rtk_output); timer.track( - &format!("grep -rn '{}' {}", pattern, path), + &format!("grep -rn '{}' {}", display_pattern, path_display), "rtk grep", &raw_output, &rtk_output, @@ -356,14 +450,6 @@ mod tests { assert!(compact.len() <= 60); } - #[test] - fn test_extra_args_accepted() { - // Test that the function signature accepts extra_args - // This is a compile-time test - if it compiles, the signature is correct - let _extra: Vec = vec!["-i".to_string(), "-A".to_string(), "3".to_string()]; - // No need to actually run - we're verifying the parameter exists - } - #[test] fn test_clean_line_multibyte() { // Thai text that exceeds max_len in bytes @@ -388,63 +474,119 @@ mod tests { assert_eq!(rg_pattern, "fn foo|pub.*bar"); } - // Fix: -r flag (grep recursive) is stripped from extra_args (rg is recursive by default) + // --- process_flag --- + #[test] - fn test_recursive_flag_stripped() { - let extra_args: Vec = vec!["-r".to_string(), "-i".to_string()]; - let filtered: Vec<&String> = extra_args - .iter() - .filter(|a| *a != "-r" && *a != "--recursive") - .collect(); - assert_eq!(filtered.len(), 1); - assert_eq!(filtered[0], "-i"); + fn test_process_flag_strip_r() { + assert_eq!(process_flag("-r"), None); + assert_eq!(process_flag("-R"), None); + assert_eq!(process_flag("--recursive"), None); + assert_eq!(process_flag("-rn"), Some("-n".to_string())); + assert_eq!(process_flag("-Rni"), Some("-ni".to_string())); + assert_eq!(process_flag("-i"), Some("-i".to_string())); + assert_eq!(process_flag("--glob"), Some("--glob".to_string())); } // --- extract_pattern_path --- + fn s(v: &[&str]) -> Vec { + v.iter().map(|x| x.to_string()).collect() + } + #[test] - fn test_extract_pattern_path_simple() { - let args = vec!["foo".to_string(), "src/".to_string()]; - let (pat, path, flags) = extract_pattern_path(&args); - assert_eq!(pat.as_deref(), Some("foo")); - assert_eq!(path, "src/"); + fn test_extract_simple() { + let (patterns, paths, flags) = extract_pattern_path(&s(&["foo", "src/"])); + assert_eq!(patterns, s(&["foo"])); + assert_eq!(paths, s(&["src/"])); assert!(flags.is_empty()); } #[test] - fn test_extract_pattern_path_with_flags() { - let args = vec!["-i".to_string(), "foo".to_string(), "src/".to_string()]; - let (pat, path, flags) = extract_pattern_path(&args); - assert_eq!(pat.as_deref(), Some("foo")); - assert_eq!(path, "src/"); - assert_eq!(flags, vec!["-i"]); + fn test_extract_with_bool_flag() { + let (patterns, paths, flags) = extract_pattern_path(&s(&["-i", "foo", "src/"])); + assert_eq!(patterns, s(&["foo"])); + assert_eq!(paths, s(&["src/"])); + assert_eq!(flags, s(&["-i"])); + } + + #[test] + fn test_extract_value_taking_flag() { + // -A 2 must not steal "error" as its value + let (patterns, paths, flags) = extract_pattern_path(&s(&["-A", "2", "error", "src"])); + assert_eq!(patterns, s(&["error"])); + assert_eq!(paths, s(&["src"])); + assert_eq!(flags, s(&["-A", "2"])); } #[test] - fn test_extract_pattern_path_default_path() { - let args = vec!["foo".to_string()]; - let (pat, path, _flags) = extract_pattern_path(&args); - assert_eq!(pat.as_deref(), Some("foo")); - assert_eq!(path, "."); + fn test_extract_cluster_strip_r() { + // -rn: r stripped, n forwarded (not leaked to rg as --replace value) + let (patterns, paths, flags) = extract_pattern_path(&s(&["-rn", "foo", "src"])); + assert_eq!(patterns, s(&["foo"])); + assert_eq!(paths, s(&["src"])); + assert_eq!(flags, s(&["-n"])); + } + + #[test] + fn test_extract_multi_path() { + let (patterns, paths, flags) = extract_pattern_path(&s(&["TODO", "src", "tests"])); + assert_eq!(patterns, s(&["TODO"])); + assert_eq!(paths, s(&["src", "tests"])); + assert!(flags.is_empty()); } #[test] - fn test_extract_pattern_path_no_args() { - let (pat, path, _flags) = extract_pattern_path(&[]); - assert!(pat.is_none()); - assert_eq!(path, "."); + fn test_extract_glob_value() { + // -g '*.md' must not steal "agent" as its value + let (patterns, paths, flags) = + extract_pattern_path(&s(&["-i", "x", "agent", "-g", "*.md"])); + assert_eq!(patterns, s(&["x"])); + assert_eq!(paths, s(&["agent"])); + assert_eq!(flags, s(&["-i", "-g", "*.md"])); } #[test] - fn test_extract_pattern_path_dashdash() { + fn test_extract_e_flag() { + let (patterns, paths, flags) = extract_pattern_path(&s(&["-e", "fn run", "src"])); + assert_eq!(patterns, s(&["fn run"])); + assert_eq!(paths, s(&["src"])); + assert!(flags.is_empty()); + } + + #[test] + fn test_extract_multi_e() { + let (patterns, paths, flags) = + extract_pattern_path(&s(&["-e", "foo", "-e", "bar", "src"])); + assert_eq!(patterns, s(&["foo", "bar"])); + assert_eq!(paths, s(&["src"])); + assert!(flags.is_empty()); + } + + #[test] + fn test_extract_dashdash_boundary() { // After --, args are positional even if they look like flags - let args = vec!["--".to_string(), "--version".to_string()]; - let (pat, path, flags) = extract_pattern_path(&args); - assert_eq!(pat.as_deref(), Some("--version")); - assert_eq!(path, "."); + let (patterns, paths, flags) = extract_pattern_path(&s(&["--", "--version"])); + assert_eq!(patterns, s(&["--version"])); + assert!(paths.is_empty()); assert!(flags.is_empty()); } + #[test] + fn test_extract_no_args() { + let (patterns, paths, flags) = extract_pattern_path(&[]); + assert!(patterns.is_empty()); + assert!(paths.is_empty()); + assert!(flags.is_empty()); + } + + #[test] + fn test_extract_default_path_empty() { + // Caller is responsible for defaulting empty paths to ["."] + let (patterns, paths, _) = extract_pattern_path(&s(&["foo"])); + assert_eq!(patterns, s(&["foo"])); + assert!(paths.is_empty()); + } + // --- truncation accuracy --- #[test] From acb3a2d5cd0a503883a420acebedb0043c4165a7 Mon Sep 17 00:00:00 2001 From: Nicolas Le Cam Date: Thu, 11 Jun 2026 00:21:46 +0200 Subject: [PATCH 06/50] fix(grep): handle value-taking flags in tail position of short clusters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A combined cluster like -rne PATTERN or -rA 2 had the last flag's value consumed only if the cluster was exactly 2 chars. Longer clusters (e.g. -rne) fell through to process_flag, which stripped -r and emitted -ne — then -e ended up in flags without a value, causing rg to error. New unified short-flag handler: for any single-dash cluster, inspect the last byte. If it is -e or a VALUE_FLAGS_SHORT member, strip the r/R chars from the prefix, emit the prefix as a flag (if non-empty), and consume the next token as the value (pattern for -e, flag value otherwise). Long flags (--foo) are still routed through process_flag. Co-Authored-By: Claude Sonnet 4.6 --- src/cmds/system/grep_cmd.rs | 106 +++++++++++++++++++++++++++--------- 1 file changed, 81 insertions(+), 25 deletions(-) diff --git a/src/cmds/system/grep_cmd.rs b/src/cmds/system/grep_cmd.rs index 9209374fee..f80ff37f42 100644 --- a/src/cmds/system/grep_cmd.rs +++ b/src/cmds/system/grep_cmd.rs @@ -71,36 +71,64 @@ fn extract_pattern_path(args: &[String]) -> (Vec, Vec, Vec Date: Thu, 11 Jun 2026 01:16:36 +0200 Subject: [PATCH 07/50] fix(grep): code and test cleanup --- src/cmds/system/grep_cmd.rs | 310 ++++++++++++++++++------------------ 1 file changed, 152 insertions(+), 158 deletions(-) diff --git a/src/cmds/system/grep_cmd.rs b/src/cmds/system/grep_cmd.rs index f80ff37f42..98433dde5e 100644 --- a/src/cmds/system/grep_cmd.rs +++ b/src/cmds/system/grep_cmd.rs @@ -14,26 +14,28 @@ use std::collections::HashMap; /// for a missing entry is a visible wrong result, not a silent corruption. const VALUE_FLAGS_SHORT: &[u8] = b"ABCgfjm"; -/// Normalise a flag arg for rg forwarding. +/// Normalise short flags arg for rg forwarding. +/// Returns `None` when the entire flag is stripped (grep-ism recursive flags). +fn strip_r(arg: &str) -> Option { + match arg + .chars() + .filter(|&c| c != 'r' && c != 'R') + .collect::() + { + s if !s.is_empty() => Some(s), + _ => None, + } +} + +/// Normalise long flag arg for rg forwarding. /// Returns `None` when the flag should be dropped (grep-ism recursive flags). -fn process_flag(arg: &str) -> Option { - if arg == "--recursive" { - return None; - } - // Long flags pass through unchanged. - if arg.starts_with("--") { - return Some(arg.to_string()); - } - // Short clusters: strip `r`/`R` (grep recursive; rg -r means --replace). - if let Some(rest) = arg.strip_prefix('-') { - let cleaned: String = rest.chars().filter(|&c| c != 'r' && c != 'R').collect(); - return if cleaned.is_empty() { - None - } else { - Some(format!("-{}", cleaned)) - }; +fn strip_recursive(arg: &str) -> Option { + match arg { + // Drop recursive flags that would change semantics in rg. + "--recursive" => None, + // Everything else pass through unchanged. + _ => Some(arg.to_string()), } - Some(arg.to_string()) } /// Extracts `(patterns, paths, flags)` from the raw trailing args. @@ -48,7 +50,7 @@ fn process_flag(arg: &str) -> Option { /// as their value so it is not mistaken for the pattern. Combined clusters like /// `-rn` have `r`/`R` stripped before forwarding. `--` marks everything after /// it as positional even if flag-shaped. -fn extract_pattern_path(args: &[String]) -> (Vec, Vec, Vec) { +fn extract_pattern_path>(args: &[T]) -> (Vec, Vec, Vec) { let mut e_patterns: Vec = Vec::new(); let mut positionals: Vec = Vec::new(); let mut flags: Vec = Vec::new(); @@ -56,10 +58,10 @@ fn extract_pattern_path(args: &[String]) -> (Vec, Vec, Vec (Vec, Vec, Vec { + let last = *rest.as_bytes().last().unwrap(); + let last_is_e = last == b'e'; + let last_takes_value = last_is_e || VALUE_FLAGS_SHORT.contains(&last); - let value = if i + 1 < args.len() { - let v = args[i + 1].clone(); - i += 2; - Some(v) - } else { - i += 1; - None - }; + if last_takes_value { + // Emit cleaned prefix (everything before last char, r/R stripped) + if let Some(prefix) = strip_r(&rest[..rest.len() - 1]) { + flags.push(format!("-{}", prefix)); + } - if last_is_e { - if let Some(v) = value { - e_patterns.push(v); + let value = if i + 1 < args.len() { + let v = args[i + 1].as_ref().to_string(); + i += 2; + Some(v) + } else { + i += 1; + None + }; + + if last_is_e { + if let Some(v) = value { + e_patterns.push(v); + } else { + // -e without a value: treat "e" as a normal flag to avoid losing the pattern. + flags.push("-e".to_string()); + } + } else { + flags.push(format!("-{}", last as char)); + if let Some(v) = value { + flags.push(v); + } } } else { - flags.push(format!("-{}", last as char)); - if let Some(v) = value { - flags.push(v); + // No value-taking flag at end: strip r/R, forward remainder + if let Some(cleaned) = strip_r(rest) { + flags.push(format!("-{}", cleaned)); } + i += 1; } - } else { - // No value-taking flag at end: strip r/R, forward remainder - let cleaned: String = rest.chars().filter(|&c| c != 'r' && c != 'R').collect(); - if !cleaned.is_empty() { - flags.push(format!("-{}", cleaned)); - } + } + _ => { + positionals.push(arg.to_string()); i += 1; } - } else { - positionals.push(arg.clone()); - i += 1; } } @@ -148,7 +143,6 @@ fn extract_pattern_path(args: &[String]) -> (Vec, Vec, Vec 0 { - eprintln!("grep: '{}' in {}", display_pattern, path_display); + eprintln!("grep: '{}' in {}", pattern_display, path_display); } let mut rg_cmd = resolved_command("rg"); @@ -261,12 +255,12 @@ pub fn run( } let args_display = if extra_args.is_empty() { - format!("'{}' {}", display_pattern, path_display) + format!("'{}' {}", pattern_display, path_display) } else { format!( "{} '{}' {}", extra_args.join(" "), - display_pattern, + pattern_display, path_display ) }; @@ -286,10 +280,10 @@ pub fn run( if exit_code == 2 && !result.stderr.trim().is_empty() { eprintln!("{}", result.stderr.trim()); } - let msg = format!("0 matches for '{}'", display_pattern); + let msg = format!("0 matches for '{}'", pattern_display); println!("{}", msg); timer.track( - &format!("grep -rn '{}' {}", display_pattern, path_display), + &format!("grep -rn '{}' {}", pattern_display, path_display), "rtk grep", &raw_output, &msg, @@ -300,7 +294,7 @@ pub fn run( let context_re = if context_only { Regex::new(&format!( "(?i).{{0,20}}{}.*", - regex::escape(&display_pattern) + regex::escape(&pattern_display) )) .ok() } else { @@ -312,7 +306,7 @@ pub fn run( let Some((file, line_num, content)) = parse_match_line(line) else { continue; }; - let cleaned = clean_line(content, max_line_len, context_re.as_ref(), &display_pattern); + let cleaned = clean_line(content, max_line_len, context_re.as_ref(), &pattern_display); by_file.entry(file).or_default().push((line_num, cleaned)); } @@ -352,7 +346,7 @@ pub fn run( print!("{}", rtk_output); timer.track( - &format!("grep -rn '{}' {}", display_pattern, path_display), + &format!("grep -rn '{}' {}", pattern_display, path_display), "rtk grep", &raw_output, &rtk_output, @@ -383,10 +377,10 @@ fn parse_match_line(line: &str) -> Option<(String, usize, &str)> { }) } -fn has_format_flag(extra_args: &[String]) -> bool { +fn has_format_flag>(extra_args: &[T]) -> bool { extra_args.iter().any(|arg| { matches!( - arg.as_str(), + arg.as_ref(), "-c" | "--count" | "-l" | "--files-with-matches" @@ -510,126 +504,122 @@ mod tests { // --- process_flag --- #[test] - fn test_process_flag_strip_r() { - assert_eq!(process_flag("-r"), None); - assert_eq!(process_flag("-R"), None); - assert_eq!(process_flag("-rn"), Some("-n".to_string())); - assert_eq!(process_flag("-Rni"), Some("-ni".to_string())); - assert_eq!(process_flag("-i"), Some("-i".to_string())); + fn test_strip_r() { + assert_eq!(strip_r(""), None); + assert_eq!(strip_r("r"), None); + assert_eq!(strip_r("rr"), None); + assert_eq!(strip_r("R"), None); + assert_eq!(strip_r("rn"), Some("n".to_string())); + assert_eq!(strip_r("Rni"), Some("ni".to_string())); + assert_eq!(strip_r("i"), Some("i".to_string())); } #[test] - fn test_process_flag_strip_recursive() { - // process_flag is now only called for long flags - assert_eq!(process_flag("--recursive"), None); - assert_eq!(process_flag("--glob"), Some("--glob".to_string())); - assert_eq!(process_flag("--type"), Some("--type".to_string())); + fn test_strip_recursive() { + assert_eq!(strip_recursive("--recursive"), None); + assert_eq!(strip_recursive("--glob"), Some("--glob".to_string())); + assert_eq!(strip_recursive("--type"), Some("--type".to_string())); } // --- extract_pattern_path --- - fn s(v: &[&str]) -> Vec { - v.iter().map(|x| x.to_string()).collect() - } - #[test] fn test_extract_simple() { - let (patterns, paths, flags) = extract_pattern_path(&s(&["foo", "src/"])); - assert_eq!(patterns, s(&["foo"])); - assert_eq!(paths, s(&["src/"])); + let (patterns, paths, flags) = extract_pattern_path(&["foo", "src/"]); + assert_eq!(patterns, vec!["foo"]); + assert_eq!(paths, vec!["src/"]); assert!(flags.is_empty()); } #[test] fn test_extract_with_bool_flag() { - let (patterns, paths, flags) = extract_pattern_path(&s(&["-i", "foo", "src/"])); - assert_eq!(patterns, s(&["foo"])); - assert_eq!(paths, s(&["src/"])); - assert_eq!(flags, s(&["-i"])); + let (patterns, paths, flags) = extract_pattern_path(&["-i", "foo", "src/"]); + assert_eq!(patterns, vec!["foo"]); + assert_eq!(paths, vec!["src/"]); + assert_eq!(flags, vec!["-i"]); } #[test] fn test_extract_value_taking_flag() { // -A 2 must not steal "error" as its value - let (patterns, paths, flags) = extract_pattern_path(&s(&["-A", "2", "error", "src"])); - assert_eq!(patterns, s(&["error"])); - assert_eq!(paths, s(&["src"])); - assert_eq!(flags, s(&["-A", "2"])); + let (patterns, paths, flags) = extract_pattern_path(&["-A", "2", "error", "src"]); + assert_eq!(patterns, vec!["error"]); + assert_eq!(paths, vec!["src"]); + assert_eq!(flags, vec!["-A", "2"]); } #[test] fn test_extract_cluster_strip_r() { // -rn: r stripped, n forwarded (not leaked to rg as --replace value) - let (patterns, paths, flags) = extract_pattern_path(&s(&["-rn", "foo", "src"])); - assert_eq!(patterns, s(&["foo"])); - assert_eq!(paths, s(&["src"])); - assert_eq!(flags, s(&["-n"])); + let (patterns, paths, flags) = extract_pattern_path(&["-rn", "foo", "src"]); + assert_eq!(patterns, vec!["foo"]); + assert_eq!(paths, vec!["src"]); + assert_eq!(flags, vec!["-n"]); } #[test] fn test_extract_cluster_ending_in_e() { // -rne PATTERN: r stripped, n in prefix, e consumes PATTERN as pattern - let (patterns, paths, flags) = extract_pattern_path(&s(&["-rne", "PATTERN", "src"])); - assert_eq!(patterns, s(&["PATTERN"])); - assert_eq!(paths, s(&["src"])); - assert_eq!(flags, s(&["-n"])); + let (patterns, paths, flags) = extract_pattern_path(&["-rne", "PATTERN", "src"]); + assert_eq!(patterns, vec!["PATTERN"]); + assert_eq!(paths, vec!["src"]); + assert_eq!(flags, vec!["-n"]); } #[test] fn test_extract_cluster_ending_in_value_flag() { // -rA 2: r stripped, A consumes 2 as context value - let (patterns, paths, flags) = extract_pattern_path(&s(&["-rA", "2", "foo", "src"])); - assert_eq!(patterns, s(&["foo"])); - assert_eq!(paths, s(&["src"])); - assert_eq!(flags, s(&["-A", "2"])); + let (patterns, paths, flags) = extract_pattern_path(&["-rA", "2", "foo", "src"]); + assert_eq!(patterns, vec!["foo"]); + assert_eq!(paths, vec!["src"]); + assert_eq!(flags, vec!["-A", "2"]); } #[test] fn test_extract_multi_path() { - let (patterns, paths, flags) = extract_pattern_path(&s(&["TODO", "src", "tests"])); - assert_eq!(patterns, s(&["TODO"])); - assert_eq!(paths, s(&["src", "tests"])); + let (patterns, paths, flags) = extract_pattern_path(&["TODO", "src", "tests"]); + assert_eq!(patterns, vec!["TODO"]); + assert_eq!(paths, vec!["src", "tests"]); assert!(flags.is_empty()); } #[test] fn test_extract_glob_value() { // -g '*.md' must not steal "agent" as its value - let (patterns, paths, flags) = - extract_pattern_path(&s(&["-i", "x", "agent", "-g", "*.md"])); - assert_eq!(patterns, s(&["x"])); - assert_eq!(paths, s(&["agent"])); - assert_eq!(flags, s(&["-i", "-g", "*.md"])); + let (patterns, paths, flags) = extract_pattern_path(&["-i", "x", "agent", "-g", "*.md"]); + assert_eq!(patterns, vec!["x"]); + assert_eq!(paths, vec!["agent"]); + assert_eq!(flags, vec!["-i", "-g", "*.md"]); } #[test] fn test_extract_e_flag() { - let (patterns, paths, flags) = extract_pattern_path(&s(&["-e", "fn run", "src"])); - assert_eq!(patterns, s(&["fn run"])); - assert_eq!(paths, s(&["src"])); + let (patterns, paths, flags) = extract_pattern_path(&["-e", "fn run", "src"]); + assert_eq!(patterns, vec!["fn run"]); + assert_eq!(paths, vec!["src"]); assert!(flags.is_empty()); } #[test] fn test_extract_multi_e() { - let (patterns, paths, flags) = extract_pattern_path(&s(&["-e", "foo", "-e", "bar", "src"])); - assert_eq!(patterns, s(&["foo", "bar"])); - assert_eq!(paths, s(&["src"])); + let (patterns, paths, flags) = extract_pattern_path(&["-e", "foo", "-e", "bar", "src"]); + assert_eq!(patterns, vec!["foo", "bar"]); + assert_eq!(paths, vec!["src"]); assert!(flags.is_empty()); } #[test] fn test_extract_dashdash_boundary() { // After --, args are positional even if they look like flags - let (patterns, paths, flags) = extract_pattern_path(&s(&["--", "--version"])); - assert_eq!(patterns, s(&["--version"])); + let (patterns, paths, flags) = extract_pattern_path(&["--", "--version"]); + assert_eq!(patterns, vec!["--version"]); assert!(paths.is_empty()); assert!(flags.is_empty()); } #[test] fn test_extract_no_args() { - let (patterns, paths, flags) = extract_pattern_path(&[]); + let (patterns, paths, flags) = extract_pattern_path::<&str>(&[]); assert!(patterns.is_empty()); assert!(paths.is_empty()); assert!(flags.is_empty()); @@ -638,11 +628,20 @@ mod tests { #[test] fn test_extract_default_path_empty() { // Caller is responsible for defaulting empty paths to ["."] - let (patterns, paths, _) = extract_pattern_path(&s(&["foo"])); - assert_eq!(patterns, s(&["foo"])); + let (patterns, paths, _) = extract_pattern_path(&["foo"]); + assert_eq!(patterns, vec!["foo"]); assert!(paths.is_empty()); } + #[test] + fn test_extract_ending_e() { + let (patterns, paths, flags) = + extract_pattern_path(&["-e", "foo", "-e", "bar", "src", "-e"]); + assert_eq!(patterns, vec!["foo", "bar"]); + assert_eq!(paths, vec!["src"]); + assert_eq!(flags, vec!["-e"]); + } + // --- truncation accuracy --- #[test] @@ -668,42 +667,37 @@ mod tests { #[test] fn test_format_flag_detects_count() { - assert!(has_format_flag(&["-c".to_string()])); - assert!(has_format_flag(&["--count".to_string()])); + assert!(has_format_flag(&["-c"])); + assert!(has_format_flag(&["--count"])); } #[test] fn test_format_flag_detects_files_with_matches() { - assert!(has_format_flag(&["-l".to_string()])); - assert!(has_format_flag(&["--files-with-matches".to_string()])); + assert!(has_format_flag(&["-l"])); + assert!(has_format_flag(&["--files-with-matches"])); } #[test] fn test_format_flag_detects_files_without_match() { - assert!(has_format_flag(&["-L".to_string()])); - assert!(has_format_flag(&["--files-without-match".to_string()])); + assert!(has_format_flag(&["-L"])); + assert!(has_format_flag(&["--files-without-match"])); } #[test] fn test_format_flag_detects_only_matching() { - assert!(has_format_flag(&["-o".to_string()])); - assert!(has_format_flag(&["--only-matching".to_string()])); + assert!(has_format_flag(&["-o"])); + assert!(has_format_flag(&["--only-matching"])); } #[test] fn test_format_flag_detects_null() { - assert!(has_format_flag(&["-Z".to_string()])); - assert!(has_format_flag(&["--null".to_string()])); + assert!(has_format_flag(&["-Z"])); + assert!(has_format_flag(&["--null"])); } #[test] fn test_format_flag_ignores_normal_flags() { - assert!(!has_format_flag(&[ - "-i".to_string(), - "-w".to_string(), - "-A".to_string(), - "3".to_string(), - ])); + assert!(!has_format_flag(&["-i", "-w", "-A", "3"])); } // Verify line numbers are always enabled in rg invocation (grep_cmd.rs:24). From c126d4594b4af1c59b8163178923207d4e9b6de4 Mon Sep 17 00:00:00 2001 From: Husam Date: Thu, 11 Jun 2026 17:47:08 +0300 Subject: [PATCH 08/50] fix(diff): report modified-only diffs and follow diff exit convention rtk diff treated files whose changes were all classified as modified (similar lines, e.g. "a: 1" vs "a: 2" in YAML/JSON) as identical, because the identical check only looked at added/removed counts. Report any non-empty change set as a difference, and exit 1 when files differ per diff convention (0 when identical). Fixes #2364 --- README.md | 2 +- scripts/test-all.sh | 4 +- src/cmds/git/diff_cmd.rs | 103 ++++++++++++++++++++++++++++++--------- src/main.rs | 4 +- 4 files changed, 86 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index d91e21b246..4e224cbe57 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,7 @@ rtk read file.rs -l aggressive # Signatures only (strips bodies) rtk smart file.rs # 2-line heuristic code summary rtk find "*.rs" . # Compact find results rtk grep "pattern" . # Grouped search results -rtk diff file1 file2 # Condensed diff +rtk diff file1 file2 # Condensed diff (exit 1 if files differ) ``` ### Git diff --git a/scripts/test-all.sh b/scripts/test-all.sh index f0e2c06b14..08a8df7a59 100755 --- a/scripts/test-all.sh +++ b/scripts/test-all.sh @@ -527,7 +527,9 @@ assert_ok "rtk discover" rtk discover section "Diff" -assert_ok "rtk diff two files" rtk diff Cargo.toml LICENSE +assert_ok "rtk diff identical files" rtk diff Cargo.toml Cargo.toml +assert_fails "rtk diff differing files" rtk diff Cargo.toml LICENSE +assert_contains "rtk diff shows changes" "added" rtk diff Cargo.toml LICENSE # ── 37. Wc ──────────────────────────────────────────── diff --git a/src/cmds/git/diff_cmd.rs b/src/cmds/git/diff_cmd.rs index 59c95b100b..4f7ae1609e 100644 --- a/src/cmds/git/diff_cmd.rs +++ b/src/cmds/git/diff_cmd.rs @@ -5,8 +5,9 @@ use anyhow::Result; use std::fs; use std::path::Path; -/// Ultra-condensed diff - only changed lines, no context -pub fn run(file1: &Path, file2: &Path, verbose: u8) -> Result<()> { +/// Ultra-condensed diff - only changed lines, no context. +/// Returns the diff-convention exit code: 0 if identical, 1 if files differ. +pub fn run(file1: &Path, file2: &Path, verbose: u8) -> Result { let timer = tracking::TimedExecution::start(); if verbose > 0 { @@ -17,39 +18,37 @@ pub fn run(file1: &Path, file2: &Path, verbose: u8) -> Result<()> { let content2 = fs::read_to_string(file2)?; let raw = format!("{}\n---\n{}", content1, content2); + let (rtk, exit_code) = render_file_diff(file1, file2, &content1, &content2); + + print!("{}", rtk); + timer.track( + &format!("diff {} {}", file1.display(), file2.display()), + "rtk diff", + &raw, + &rtk, + ); + Ok(exit_code) +} + +/// Renders the condensed file comparison and returns it with the +/// diff-convention exit code (0 = identical, 1 = differences found). +fn render_file_diff(file1: &Path, file2: &Path, content1: &str, content2: &str) -> (String, i32) { let lines1: Vec<&str> = content1.lines().collect(); let lines2: Vec<&str> = content2.lines().collect(); let diff = compute_diff(&lines1, &lines2); - let mut rtk = String::new(); - if diff.added == 0 && diff.removed == 0 { - rtk.push_str("[ok] Files are identical"); - println!("{}", rtk); - timer.track( - &format!("diff {} {}", file1.display(), file2.display()), - "rtk diff", - &raw, - &rtk, - ); - return Ok(()); + if diff.changes.is_empty() { + return ("[ok] Files are identical\n".to_string(), 0); } + let mut rtk = String::new(); rtk.push_str(&format!("{} → {}\n", file1.display(), file2.display())); rtk.push_str(&format!( " +{} added, -{} removed, ~{} modified\n\n", diff.added, diff.removed, diff.modified )); - rtk.push_str(&format_diff_changes(&diff)); - - print!("{}", rtk); - timer.track( - &format!("diff {} {}", file1.display(), file2.display()), - "rtk diff", - &raw, - &rtk, - ); - Ok(()) + (rtk, 1) } /// Run diff from stdin (piped command output) @@ -307,6 +306,64 @@ mod tests { assert!(result.changes.is_empty()); } + // --- render_file_diff (issue #2364 regression) --- + + #[test] + fn test_render_modified_only_yaml_not_identical() { + // "a: 1" vs "a: 2" is classified as modified (similarity > 0.5); + // the identical check must not ignore modified-only diffs. + let (out, code) = render_file_diff( + Path::new("one.yaml"), + Path::new("two.yaml"), + "a: 1\n", + "a: 2\n", + ); + assert!( + !out.contains("identical"), + "modified-only diff reported as identical:\n{}", + out + ); + assert!(out.contains("~1 modified")); + assert!(out.contains("a: 1")); + assert!(out.contains("a: 2")); + assert_eq!(code, 1, "differing files must exit 1 (diff convention)"); + } + + #[test] + fn test_render_modified_only_json_not_identical() { + let (out, code) = render_file_diff( + Path::new("j1.json"), + Path::new("j2.json"), + "{\"a\": 1}\n", + "{\"a\": 2}\n", + ); + assert!( + !out.contains("identical"), + "modified-only diff reported as identical:\n{}", + out + ); + assert_eq!(code, 1); + } + + #[test] + fn test_render_identical_files_exit_zero() { + let (out, code) = render_file_diff( + Path::new("a.yaml"), + Path::new("b.yaml"), + "a: 1\nb: 2\n", + "a: 1\nb: 2\n", + ); + assert!(out.contains("[ok] Files are identical")); + assert_eq!(code, 0); + } + + #[test] + fn test_render_added_removed_exit_one() { + let (out, code) = render_file_diff(Path::new("t1.txt"), Path::new("t2.txt"), "x\n", "y\n"); + assert!(out.contains("+1 added, -1 removed")); + assert_eq!(code, 1); + } + // --- condense_unified_diff --- #[test] diff --git a/src/main.rs b/src/main.rs index a5bde544a3..6da0a0ab27 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1707,11 +1707,11 @@ fn run_cli() -> Result { Commands::Diff { file1, file2 } => { if let Some(f2) = file2 { - diff_cmd::run(&file1, &f2, cli.verbose)?; + diff_cmd::run(&file1, &f2, cli.verbose)? } else { diff_cmd::run_stdin(cli.verbose)?; + 0 } - 0 } Commands::Log { file } => { From b7d93b509e67eee46de3bb518e7ca71429188a4b Mon Sep 17 00:00:00 2001 From: Nicolas Le Cam Date: Sun, 14 Jun 2026 11:44:32 +0200 Subject: [PATCH 09/50] fix(grep): left-to-right cluster scan, long value flags, format passthrough MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five regressions on grep_arg_parsing vs develop: - `-ecarrot` matched "caot" — strip_r was applied to the inline value bytes. Fix: scan cluster left-to-right; stop at first value-taking letter and treat the remainder as the inline value without r/R stripping. - `-g*.rs` glob became `*.s` — same root cause, inline glob value r-stripped. - `--glob '*.md'` took `*.md` as a path — no VALUE_FLAGS_LONG existed; the value token fell through to the positional collector. Fix: add VALUE_FLAGS_LONG covering all rg long flags that take a value. - `--max-count 1 fn file` took `1` as the pattern — same missing long flag. - VALUE_FLAGS_SHORT expanded: add t/T (--type/--type-not), d (--max-depth), M (--max-columns) so inline short forms and space-separated forms parse correctly without mis-routing values to patterns or paths. - --regexp (long form of -e) now routes to patterns like -e does. - has_format_flag: add --count-matches, --json, --passthru, --files so these output-format modes get passthrough treatment instead of silent "0 matches in 0 files". 2183 tests pass. Co-Authored-By: Claude Sonnet 4.6 --- src/cmds/system/grep_cmd.rs | 371 ++++++++++++++++++++++++++++-------- 1 file changed, 292 insertions(+), 79 deletions(-) diff --git a/src/cmds/system/grep_cmd.rs b/src/cmds/system/grep_cmd.rs index 98433dde5e..e329472508 100644 --- a/src/cmds/system/grep_cmd.rs +++ b/src/cmds/system/grep_cmd.rs @@ -8,48 +8,67 @@ use anyhow::{Context, Result}; use regex::Regex; use std::collections::HashMap; -/// Short flags (exact 2-char form `-X`) that consume one following token as their value. -/// `-e` is handled separately — its value goes into `patterns`, not `flags`. -/// Deliberately small: unknown flags pass through unchanged. The failure mode -/// for a missing entry is a visible wrong result, not a silent corruption. -const VALUE_FLAGS_SHORT: &[u8] = b"ABCgfjm"; - -/// Normalise short flags arg for rg forwarding. -/// Returns `None` when the entire flag is stripped (grep-ism recursive flags). -fn strip_r(arg: &str) -> Option { - match arg - .chars() - .filter(|&c| c != 'r' && c != 'R') - .collect::() - { - s if !s.is_empty() => Some(s), - _ => None, - } -} - -/// Normalise long flag arg for rg forwarding. -/// Returns `None` when the flag should be dropped (grep-ism recursive flags). +/// Short single-char flags that consume one following token (or inline remainder) +/// as their value. `-e` is handled separately — its value goes to `patterns`. +/// Includes all rg short flags that take a value argument except `-e` and `-r` +/// (stripped) and `-E` (dialect, left to #2138). Failure mode for a missing +/// entry: the value becomes a positional (visible wrong result, not silent). +const VALUE_FLAGS_SHORT: &[u8] = b"ABCgfjmtTdM"; + +/// Long flags that consume the NEXT token as their value (space-separated form). +/// Inline `=` form (`--flag=value`) is one token and passes through unchanged. +/// `--regexp` is handled separately (its value goes to `patterns`). +/// `--encoding` value is consumed correctly here; dialect routing is #2138's job. +const VALUE_FLAGS_LONG: &[&str] = &[ + "--file", + "--glob", + "--iglob", + "--type", + "--type-not", + "--type-add", + "--type-clear", + "--max-count", + "--max-depth", + "--max-filesize", + "--max-columns", + "--after-context", + "--before-context", + "--context", + "--encoding", + "--engine", + "--sort", + "--sortr", + "--threads", + "--replace", + "--pre", + "--pre-glob", + "--path-separator", + "--ignore-file", + "--field-match-separator", + "--field-context-separator", + "--context-separator", + "--color", + "--colors", +]; + +/// Drop `--recursive` (grep-ism); pass all other long flags through unchanged. fn strip_recursive(arg: &str) -> Option { match arg { - // Drop recursive flags that would change semantics in rg. "--recursive" => None, - // Everything else pass through unchanged. _ => Some(arg.to_string()), } } /// Extracts `(patterns, paths, flags)` from the raw trailing args. /// -/// - `patterns`: first non-flag positional prepended to any `-e` values. -/// All patterns are passed to rg as `-e` flags, so positional and `-e` are -/// interchangeable from rg's perspective. Empty → caller should error. -/// - `paths`: all subsequent non-flag positionals. Empty → caller defaults to `["."]`. -/// - `flags`: other flags forwarded to rg (recursive flags already stripped). +/// - `patterns`: positional pattern + all `-e`/`--regexp` values. Empty → error. +/// - `paths`: subsequent non-flag positionals. Empty → caller defaults to `["."]`. +/// - `flags`: other flags forwarded to rg (`-r`/`-R`/`--recursive` stripped). /// -/// Value-taking short flags (see `VALUE_FLAGS_SHORT`) consume the next token -/// as their value so it is not mistaken for the pattern. Combined clusters like -/// `-rn` have `r`/`R` stripped before forwarding. `--` marks everything after -/// it as positional even if flag-shaped. +/// Short clusters are scanned left-to-right; the first value-taking letter +/// terminates the cluster — everything after it is its inline value, not a +/// separate flag. Long value-taking flags consume the next token. `--` marks +/// everything after it as positional. fn extract_pattern_path>(args: &[T]) -> (Vec, Vec, Vec) { let mut e_patterns: Vec = Vec::new(); let mut positionals: Vec = Vec::new(); @@ -72,8 +91,29 @@ fn extract_pattern_path>(args: &[T]) -> (Vec, Vec, continue; } - // Long flags (--foo, --recursive): strip or pass through unchanged if arg.starts_with("--") { + // --regexp is the long form of -e: value goes to patterns. + if arg == "--regexp" { + if i + 1 < args.len() { + e_patterns.push(args[i + 1].as_ref().to_string()); + i += 2; + } else { + i += 1; + } + continue; + } + // Other long value-taking flags: consume next token as value. + if VALUE_FLAGS_LONG.contains(&arg) { + flags.push(arg.to_string()); + if i + 1 < args.len() { + flags.push(args[i + 1].as_ref().to_string()); + i += 2; + } else { + i += 1; + } + continue; + } + // Drop --recursive; pass everything else through. if let Some(cleaned) = strip_recursive(arg) { flags.push(cleaned); } @@ -83,42 +123,56 @@ fn extract_pattern_path>(args: &[T]) -> (Vec, Vec, match arg.strip_prefix('-') { Some(rest) if !rest.is_empty() => { - let last = *rest.as_bytes().last().unwrap(); - let last_is_e = last == b'e'; - let last_takes_value = last_is_e || VALUE_FLAGS_SHORT.contains(&last); - - if last_takes_value { - // Emit cleaned prefix (everything before last char, r/R stripped) - if let Some(prefix) = strip_r(&rest[..rest.len() - 1]) { - flags.push(format!("-{}", prefix)); + // Left-to-right scan: r/R stripped, first value-taking char terminates. + let bytes = rest.as_bytes(); + let mut bool_acc = String::new(); + let mut consumed_value = false; + let mut j = 0; + while j < bytes.len() { + let ch = bytes[j]; + if ch == b'r' || ch == b'R' { + j += 1; + continue; } - - let value = if i + 1 < args.len() { - let v = args[i + 1].as_ref().to_string(); - i += 2; - Some(v) - } else { - i += 1; - None - }; - - if last_is_e { - if let Some(v) = value { - e_patterns.push(v); - } else { - // -e without a value: treat "e" as a normal flag to avoid losing the pattern. - flags.push("-e".to_string()); + let is_e = ch == b'e'; + if is_e || VALUE_FLAGS_SHORT.contains(&ch) { + if !bool_acc.is_empty() { + flags.push(format!("-{}", bool_acc)); } - } else { - flags.push(format!("-{}", last as char)); - if let Some(v) = value { - flags.push(v); + // Inline value = bytes after this char in the cluster. + let inline = std::str::from_utf8(&bytes[j + 1..]).unwrap_or(""); + if is_e { + if !inline.is_empty() { + e_patterns.push(inline.to_string()); + i += 1; + } else if i + 1 < args.len() { + e_patterns.push(args[i + 1].as_ref().to_string()); + i += 2; + } else { + flags.push("-e".to_string()); + i += 1; + } + } else { + flags.push(format!("-{}", ch as char)); + if !inline.is_empty() { + flags.push(inline.to_string()); + i += 1; + } else if i + 1 < args.len() { + flags.push(args[i + 1].as_ref().to_string()); + i += 2; + } else { + i += 1; + } } + consumed_value = true; + break; } - } else { - // No value-taking flag at end: strip r/R, forward remainder - if let Some(cleaned) = strip_r(rest) { - flags.push(format!("-{}", cleaned)); + bool_acc.push(ch as char); + j += 1; + } + if !consumed_value { + if !bool_acc.is_empty() { + flags.push(format!("-{}", bool_acc)); } i += 1; } @@ -130,8 +184,8 @@ fn extract_pattern_path>(args: &[T]) -> (Vec, Vec, } } - // If -e was used: all positionals are paths; -e values are the patterns. - // If -e was not used: first positional is the pattern, rest are paths. + // If -e/--regexp was used: all positionals are paths. + // Otherwise: first positional is the pattern, rest are paths. let (patterns, paths) = if !e_patterns.is_empty() { (e_patterns, positionals) } else { @@ -382,6 +436,7 @@ fn has_format_flag>(extra_args: &[T]) -> bool { matches!( arg.as_ref(), "-c" | "--count" + | "--count-matches" | "-l" | "--files-with-matches" | "-L" @@ -390,6 +445,9 @@ fn has_format_flag>(extra_args: &[T]) -> bool { | "--only-matching" | "-Z" | "--null" + | "--json" + | "--passthru" + | "--files" ) }) } @@ -501,18 +559,7 @@ mod tests { assert_eq!(rg_pattern, "fn foo|pub.*bar"); } - // --- process_flag --- - - #[test] - fn test_strip_r() { - assert_eq!(strip_r(""), None); - assert_eq!(strip_r("r"), None); - assert_eq!(strip_r("rr"), None); - assert_eq!(strip_r("R"), None); - assert_eq!(strip_r("rn"), Some("n".to_string())); - assert_eq!(strip_r("Rni"), Some("ni".to_string())); - assert_eq!(strip_r("i"), Some("i".to_string())); - } + // --- strip_recursive --- #[test] fn test_strip_recursive() { @@ -642,6 +689,172 @@ mod tests { assert_eq!(flags, vec!["-e"]); } + // --- inline short flag values (Bug 5) --- + + #[test] + fn test_extract_inline_e_value() { + // -ecarrot: e hits at j=0, inline="carrot", no r-stripping on value + let (patterns, paths, flags) = extract_pattern_path(&["-ecarrot", "file"]); + assert_eq!(patterns, vec!["carrot"]); + assert_eq!(paths, vec!["file"]); + assert!(flags.is_empty()); + } + + #[test] + fn test_extract_inline_e_value_no_rstrip() { + // -ecarrot: the 'r' in "carrot" must NOT be stripped (it's value, not a flag) + let (patterns, _, _) = extract_pattern_path(&["-ecarrot", "file"]); + assert_eq!(patterns, vec!["carrot"], "r in inline value must not be stripped"); + } + + #[test] + fn test_extract_inline_g_value() { + // -g*.rs: g hits at j=0, inline="*.rs", no r-stripping on value + let (patterns, paths, flags) = extract_pattern_path(&["aaa", "sub", "-g*.rs"]); + assert_eq!(patterns, vec!["aaa"]); + assert_eq!(paths, vec!["sub"]); + assert_eq!(flags, vec!["-g", "*.rs"]); + } + + #[test] + fn test_extract_inline_g_value_no_rstrip() { + // -g*.rs: the 'r' in "*.rs" must NOT be stripped + let (_, _, flags) = extract_pattern_path(&["aaa", "sub", "-g*.rs"]); + assert!(flags.contains(&"*.rs".to_string()), "r in glob value must not be stripped"); + } + + // --- long value-taking flags (Bug 5) --- + + #[test] + fn test_extract_long_glob_value() { + let (patterns, paths, flags) = + extract_pattern_path(&["compact", "sub", "--glob", "*.md"]); + assert_eq!(patterns, vec!["compact"]); + assert_eq!(paths, vec!["sub"]); + assert_eq!(flags, vec!["--glob", "*.md"]); + } + + #[test] + fn test_extract_long_max_count() { + let (patterns, paths, flags) = + extract_pattern_path(&["--max-count", "1", "fn", "file"]); + assert_eq!(patterns, vec!["fn"]); + assert_eq!(paths, vec!["file"]); + assert_eq!(flags, vec!["--max-count", "1"]); + } + + #[test] + fn test_extract_short_type() { + // -t rust: type filter, value must not become pattern + let (patterns, paths, flags) = extract_pattern_path(&["-t", "rust", "fn", "src"]); + assert_eq!(patterns, vec!["fn"]); + assert_eq!(paths, vec!["src"]); + assert_eq!(flags, vec!["-t", "rust"]); + } + + #[test] + fn test_extract_short_max_depth() { + // -d 3: max-depth, value must not become pattern + let (patterns, paths, flags) = extract_pattern_path(&["-d", "3", "foo", "src"]); + assert_eq!(patterns, vec!["foo"]); + assert_eq!(paths, vec!["src"]); + assert_eq!(flags, vec!["-d", "3"]); + } + + #[test] + fn test_extract_short_max_columns() { + // -M 120: max-columns, value must not become pattern + let (patterns, paths, flags) = extract_pattern_path(&["-M", "120", "foo", "src"]); + assert_eq!(patterns, vec!["foo"]); + assert_eq!(paths, vec!["src"]); + assert_eq!(flags, vec!["-M", "120"]); + } + + #[test] + fn test_extract_long_regexp() { + // --regexp is the long form of -e; value goes to patterns + let (patterns, paths, flags) = extract_pattern_path(&["--regexp", "fn run", "src"]); + assert_eq!(patterns, vec!["fn run"]); + assert_eq!(paths, vec!["src"]); + assert!(flags.is_empty()); + } + + #[test] + fn test_extract_long_regexp_multi() { + // --regexp can be combined with -e + let (patterns, paths, _) = + extract_pattern_path(&["--regexp", "foo", "-e", "bar", "src"]); + assert_eq!(patterns, vec!["foo", "bar"]); + assert_eq!(paths, vec!["src"]); + } + + #[test] + fn test_extract_long_ignore_file() { + let (patterns, paths, flags) = + extract_pattern_path(&["--ignore-file", ".myignore", "foo", "src"]); + assert_eq!(patterns, vec!["foo"]); + assert_eq!(paths, vec!["src"]); + assert_eq!(flags, vec!["--ignore-file", ".myignore"]); + } + + #[test] + fn test_extract_long_engine() { + let (patterns, paths, flags) = + extract_pattern_path(&["--engine", "pcre2", "foo", "src"]); + assert_eq!(patterns, vec!["foo"]); + assert_eq!(paths, vec!["src"]); + assert_eq!(flags, vec!["--engine", "pcre2"]); + } + + #[test] + fn test_extract_long_type_clear() { + let (patterns, paths, flags) = + extract_pattern_path(&["--type-clear", "rust", "foo", "src"]); + assert_eq!(patterns, vec!["foo"]); + assert_eq!(paths, vec!["src"]); + assert_eq!(flags, vec!["--type-clear", "rust"]); + } + + #[test] + fn test_extract_long_path_separator() { + let (patterns, paths, flags) = + extract_pattern_path(&["--path-separator", "/", "foo", "src"]); + assert_eq!(patterns, vec!["foo"]); + assert_eq!(paths, vec!["src"]); + assert_eq!(flags, vec!["--path-separator", "/"]); + } + + #[test] + fn test_extract_long_flag_inline_eq_passthrough() { + // --glob=*.rs is one token (inline =): passes through as-is, not consumed as pair + let (patterns, paths, flags) = extract_pattern_path(&["foo", "src", "--glob=*.rs"]); + assert_eq!(patterns, vec!["foo"]); + assert_eq!(paths, vec!["src"]); + assert_eq!(flags, vec!["--glob=*.rs"]); + } + + // --- has_format_flag additions --- + + #[test] + fn test_format_flag_detects_count_matches() { + assert!(has_format_flag(&["--count-matches"])); + } + + #[test] + fn test_format_flag_detects_json() { + assert!(has_format_flag(&["--json"])); + } + + #[test] + fn test_format_flag_detects_passthru() { + assert!(has_format_flag(&["--passthru"])); + } + + #[test] + fn test_format_flag_detects_files() { + assert!(has_format_flag(&["--files"])); + } + // --- truncation accuracy --- #[test] From 8d29f75833c9afe5eebdb5d6d1ce1743503421e7 Mon Sep 17 00:00:00 2001 From: Nicolas Le Cam Date: Sun, 14 Jun 2026 12:18:29 +0200 Subject: [PATCH 10/50] fix(grep): restore strip_r as explicit testable helper + pre-existing clippy fix The left-to-right cluster scan now calls strip_r() at emit time on the accumulated flag-letter prefix (raw_prefix), never on inline value bytes. This makes the safety contract explicit and testable: strip_r("carrot") produces "caot", documenting exactly why it must not touch value bytes. Also fix pre-existing unused-mut in aws_cmd.rs (has_output_flag). Co-Authored-By: Claude Sonnet 4.6 --- src/cmds/system/grep_cmd.rs | 85 +++++++++++++++++++++++++++---------- 1 file changed, 63 insertions(+), 22 deletions(-) diff --git a/src/cmds/system/grep_cmd.rs b/src/cmds/system/grep_cmd.rs index e329472508..25907023c5 100644 --- a/src/cmds/system/grep_cmd.rs +++ b/src/cmds/system/grep_cmd.rs @@ -51,6 +51,24 @@ const VALUE_FLAGS_LONG: &[&str] = &[ "--colors", ]; +/// Strip `r`/`R` from the boolean-flag portion of a short cluster. +/// Returns `None` when nothing remains (the cluster was purely recursive flags). +/// +/// IMPORTANT: must only be called on the accumulated flag-letter prefix, never +/// on inline values — `strip_r("carrot")` would corrupt the value to `"caot"`, +/// which is exactly the original bug this function replaces. +fn strip_r(flag_letters: &str) -> Option { + let s: String = flag_letters + .chars() + .filter(|&c| c != 'r' && c != 'R') + .collect(); + if s.is_empty() { + None + } else { + Some(s) + } +} + /// Drop `--recursive` (grep-ism); pass all other long flags through unchanged. fn strip_recursive(arg: &str) -> Option { match arg { @@ -123,23 +141,23 @@ fn extract_pattern_path>(args: &[T]) -> (Vec, Vec, match arg.strip_prefix('-') { Some(rest) if !rest.is_empty() => { - // Left-to-right scan: r/R stripped, first value-taking char terminates. + // Left-to-right scan: accumulate flag letters (including r/R) into + // raw_prefix; strip_r is called at emit time so it is never applied + // to inline value bytes (which follow the first value-taking char). let bytes = rest.as_bytes(); - let mut bool_acc = String::new(); + let mut raw_prefix = String::new(); let mut consumed_value = false; let mut j = 0; while j < bytes.len() { let ch = bytes[j]; - if ch == b'r' || ch == b'R' { - j += 1; - continue; - } let is_e = ch == b'e'; if is_e || VALUE_FLAGS_SHORT.contains(&ch) { - if !bool_acc.is_empty() { - flags.push(format!("-{}", bool_acc)); + // Emit the boolean prefix with r/R stripped. + if let Some(prefix) = strip_r(&raw_prefix) { + flags.push(format!("-{}", prefix)); } // Inline value = bytes after this char in the cluster. + // strip_r is NOT called here — these are value bytes, not flags. let inline = std::str::from_utf8(&bytes[j + 1..]).unwrap_or(""); if is_e { if !inline.is_empty() { @@ -167,12 +185,13 @@ fn extract_pattern_path>(args: &[T]) -> (Vec, Vec, consumed_value = true; break; } - bool_acc.push(ch as char); + raw_prefix.push(ch as char); j += 1; } if !consumed_value { - if !bool_acc.is_empty() { - flags.push(format!("-{}", bool_acc)); + // Pure boolean cluster: strip r/R, emit remainder. + if let Some(stripped) = strip_r(&raw_prefix) { + flags.push(format!("-{}", stripped)); } i += 1; } @@ -559,7 +578,26 @@ mod tests { assert_eq!(rg_pattern, "fn foo|pub.*bar"); } - // --- strip_recursive --- + // --- strip_r / strip_recursive --- + + #[test] + fn test_strip_r() { + // Pure r/R → None (whole cluster dropped) + assert_eq!(strip_r("r"), None); + assert_eq!(strip_r("R"), None); + assert_eq!(strip_r("rr"), None); + assert_eq!(strip_r("rR"), None); + assert_eq!(strip_r(""), None); + // Mixed → r/R removed, rest kept + assert_eq!(strip_r("rn"), Some("n".to_string())); + assert_eq!(strip_r("Rni"), Some("ni".to_string())); + assert_eq!(strip_r("rni"), Some("ni".to_string())); + assert_eq!(strip_r("i"), Some("i".to_string())); + // Reveals the danger: strip_r must NEVER be called on value bytes. + // Calling it on "carrot" strips both 'r's and produces "caot" — this + // is exactly the original bug (-ecarrot matching "caot" instead of "carrot"). + assert_eq!(strip_r("carrot"), Some("caot".to_string())); + } #[test] fn test_strip_recursive() { @@ -704,7 +742,11 @@ mod tests { fn test_extract_inline_e_value_no_rstrip() { // -ecarrot: the 'r' in "carrot" must NOT be stripped (it's value, not a flag) let (patterns, _, _) = extract_pattern_path(&["-ecarrot", "file"]); - assert_eq!(patterns, vec!["carrot"], "r in inline value must not be stripped"); + assert_eq!( + patterns, + vec!["carrot"], + "r in inline value must not be stripped" + ); } #[test] @@ -720,15 +762,17 @@ mod tests { fn test_extract_inline_g_value_no_rstrip() { // -g*.rs: the 'r' in "*.rs" must NOT be stripped let (_, _, flags) = extract_pattern_path(&["aaa", "sub", "-g*.rs"]); - assert!(flags.contains(&"*.rs".to_string()), "r in glob value must not be stripped"); + assert!( + flags.contains(&"*.rs".to_string()), + "r in glob value must not be stripped" + ); } // --- long value-taking flags (Bug 5) --- #[test] fn test_extract_long_glob_value() { - let (patterns, paths, flags) = - extract_pattern_path(&["compact", "sub", "--glob", "*.md"]); + let (patterns, paths, flags) = extract_pattern_path(&["compact", "sub", "--glob", "*.md"]); assert_eq!(patterns, vec!["compact"]); assert_eq!(paths, vec!["sub"]); assert_eq!(flags, vec!["--glob", "*.md"]); @@ -736,8 +780,7 @@ mod tests { #[test] fn test_extract_long_max_count() { - let (patterns, paths, flags) = - extract_pattern_path(&["--max-count", "1", "fn", "file"]); + let (patterns, paths, flags) = extract_pattern_path(&["--max-count", "1", "fn", "file"]); assert_eq!(patterns, vec!["fn"]); assert_eq!(paths, vec!["file"]); assert_eq!(flags, vec!["--max-count", "1"]); @@ -782,8 +825,7 @@ mod tests { #[test] fn test_extract_long_regexp_multi() { // --regexp can be combined with -e - let (patterns, paths, _) = - extract_pattern_path(&["--regexp", "foo", "-e", "bar", "src"]); + let (patterns, paths, _) = extract_pattern_path(&["--regexp", "foo", "-e", "bar", "src"]); assert_eq!(patterns, vec!["foo", "bar"]); assert_eq!(paths, vec!["src"]); } @@ -799,8 +841,7 @@ mod tests { #[test] fn test_extract_long_engine() { - let (patterns, paths, flags) = - extract_pattern_path(&["--engine", "pcre2", "foo", "src"]); + let (patterns, paths, flags) = extract_pattern_path(&["--engine", "pcre2", "foo", "src"]); assert_eq!(patterns, vec!["foo"]); assert_eq!(paths, vec!["src"]); assert_eq!(flags, vec!["--engine", "pcre2"]); From 34aac6ed95b36d40d82bcbbc12fe3732070e7759 Mon Sep 17 00:00:00 2001 From: Nicolas Le Cam Date: Sun, 14 Jun 2026 15:24:20 +0200 Subject: [PATCH 11/50] refactor(grep): extract parse_cluster to make cluster parsing directly testable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The left-to-right scanning logic now lives entirely in parse_cluster(), which returns a ClusterResult enum (Boolean or ValueTaking). extract_pattern_path just dispatches on the result — no loop logic inline. parse_cluster() is the only place that touches cluster bytes, so the full contract is testable in isolation: parse_cluster("ecarrot") → ValueTaking { prefix: None, flag: 'e', inline: "carrot" } parse_cluster("rne") → ValueTaking { prefix: Some("n"), flag: 'e', inline: "" } parse_cluster("g*.rs") → ValueTaking { prefix: None, flag: 'g', inline: "*.rs" } parse_cluster("rn") → Boolean(Some("n")) strip_r() remains as the focused r/R-stripping helper used internally by parse_cluster (and tested directly to document the "carrot"→"caot" danger). Co-Authored-By: Claude Sonnet 4.6 --- src/cmds/system/grep_cmd.rs | 249 +++++++++++++++++++++++++++--------- 1 file changed, 185 insertions(+), 64 deletions(-) diff --git a/src/cmds/system/grep_cmd.rs b/src/cmds/system/grep_cmd.rs index 25907023c5..e7639e1f36 100644 --- a/src/cmds/system/grep_cmd.rs +++ b/src/cmds/system/grep_cmd.rs @@ -51,12 +51,63 @@ const VALUE_FLAGS_LONG: &[&str] = &[ "--colors", ]; -/// Strip `r`/`R` from the boolean-flag portion of a short cluster. -/// Returns `None` when nothing remains (the cluster was purely recursive flags). +/// Result of parsing the content of a short flag cluster (the part after `-`). +#[derive(Debug, PartialEq)] +enum ClusterResult { + /// All chars were boolean flags or `r`/`R` (stripped). + /// `None` when the entire cluster reduces to nothing after stripping. + Boolean(Option), + /// A value-taking flag was encountered. Scanning stops here. + ValueTaking { + /// Boolean flags before the value-taking char, `r`/`R` stripped. + prefix: Option, + /// The value-taking flag char (`e`, `A`, `g`, etc.). + flag: char, + /// Bytes after `flag` in the cluster — its inline value. + /// Empty string means "consume the next token instead." + inline: String, + }, +} + +/// Parse the content of a short flag cluster (everything after the leading `-`). /// -/// IMPORTANT: must only be called on the accumulated flag-letter prefix, never -/// on inline values — `strip_r("carrot")` would corrupt the value to `"caot"`, -/// which is exactly the original bug this function replaces. +/// Scans left-to-right: strips `r`/`R`, accumulates boolean flag letters, and +/// stops at the first value-taking flag (from `VALUE_FLAGS_SHORT` or `e`). +/// Everything after that flag char in the cluster is its inline value and is +/// returned verbatim — no `r`/`R` stripping is applied to it. +/// +/// This is the only place in the codebase that touches cluster bytes. +fn parse_cluster(rest: &str) -> ClusterResult { + let bytes = rest.as_bytes(); + let mut raw_prefix = String::new(); + let mut j = 0; + while j < bytes.len() { + let ch = bytes[j]; + let is_e = ch == b'e'; + if is_e || VALUE_FLAGS_SHORT.contains(&ch) { + let prefix = strip_r(&raw_prefix); + // Inline value = bytes after this char; returned verbatim (no stripping). + let inline = std::str::from_utf8(&bytes[j + 1..]) + .unwrap_or("") + .to_string(); + return ClusterResult::ValueTaking { + prefix, + flag: ch as char, + inline, + }; + } + raw_prefix.push(ch as char); + j += 1; + } + ClusterResult::Boolean(strip_r(&raw_prefix)) +} + +/// Strip `r`/`R` from a string of flag letters. +/// Returns `None` when nothing remains after stripping. +/// +/// Only called on accumulated flag letters (never on inline values). +/// `strip_r("carrot")` → `Some("caot")` — this shows exactly why it must not +/// touch value bytes; that corruption was the original `-ecarrot` bug. fn strip_r(flag_letters: &str) -> Option { let s: String = flag_letters .chars() @@ -140,62 +191,46 @@ fn extract_pattern_path>(args: &[T]) -> (Vec, Vec, } match arg.strip_prefix('-') { - Some(rest) if !rest.is_empty() => { - // Left-to-right scan: accumulate flag letters (including r/R) into - // raw_prefix; strip_r is called at emit time so it is never applied - // to inline value bytes (which follow the first value-taking char). - let bytes = rest.as_bytes(); - let mut raw_prefix = String::new(); - let mut consumed_value = false; - let mut j = 0; - while j < bytes.len() { - let ch = bytes[j]; - let is_e = ch == b'e'; - if is_e || VALUE_FLAGS_SHORT.contains(&ch) { - // Emit the boolean prefix with r/R stripped. - if let Some(prefix) = strip_r(&raw_prefix) { - flags.push(format!("-{}", prefix)); + Some(rest) if !rest.is_empty() => match parse_cluster(rest) { + ClusterResult::Boolean(prefix) => { + if let Some(s) = prefix { + flags.push(format!("-{}", s)); + } + i += 1; + } + ClusterResult::ValueTaking { + prefix, + flag, + inline, + } => { + if let Some(s) = prefix { + flags.push(format!("-{}", s)); + } + if flag == 'e' { + if !inline.is_empty() { + e_patterns.push(inline); + i += 1; + } else if i + 1 < args.len() { + e_patterns.push(args[i + 1].as_ref().to_string()); + i += 2; + } else { + flags.push("-e".to_string()); + i += 1; } - // Inline value = bytes after this char in the cluster. - // strip_r is NOT called here — these are value bytes, not flags. - let inline = std::str::from_utf8(&bytes[j + 1..]).unwrap_or(""); - if is_e { - if !inline.is_empty() { - e_patterns.push(inline.to_string()); - i += 1; - } else if i + 1 < args.len() { - e_patterns.push(args[i + 1].as_ref().to_string()); - i += 2; - } else { - flags.push("-e".to_string()); - i += 1; - } + } else { + flags.push(format!("-{}", flag)); + if !inline.is_empty() { + flags.push(inline); + i += 1; + } else if i + 1 < args.len() { + flags.push(args[i + 1].as_ref().to_string()); + i += 2; } else { - flags.push(format!("-{}", ch as char)); - if !inline.is_empty() { - flags.push(inline.to_string()); - i += 1; - } else if i + 1 < args.len() { - flags.push(args[i + 1].as_ref().to_string()); - i += 2; - } else { - i += 1; - } + i += 1; } - consumed_value = true; - break; } - raw_prefix.push(ch as char); - j += 1; } - if !consumed_value { - // Pure boolean cluster: strip r/R, emit remainder. - if let Some(stripped) = strip_r(&raw_prefix) { - flags.push(format!("-{}", stripped)); - } - i += 1; - } - } + }, _ => { positionals.push(arg.to_string()); i += 1; @@ -578,27 +613,113 @@ mod tests { assert_eq!(rg_pattern, "fn foo|pub.*bar"); } - // --- strip_r / strip_recursive --- + // --- parse_cluster --- + + fn vt(prefix: Option<&str>, flag: char, inline: &str) -> ClusterResult { + ClusterResult::ValueTaking { + prefix: prefix.map(|s| s.to_string()), + flag, + inline: inline.to_string(), + } + } + + #[test] + fn test_parse_cluster_boolean_only() { + // Pure boolean clusters: r/R stripped, remainder emitted + assert_eq!(parse_cluster("r"), ClusterResult::Boolean(None)); + assert_eq!(parse_cluster("R"), ClusterResult::Boolean(None)); + assert_eq!(parse_cluster("rR"), ClusterResult::Boolean(None)); + assert_eq!(parse_cluster("rn"), ClusterResult::Boolean(Some("n".to_string()))); + assert_eq!(parse_cluster("Rni"), ClusterResult::Boolean(Some("ni".to_string()))); + assert_eq!(parse_cluster("n"), ClusterResult::Boolean(Some("n".to_string()))); + assert_eq!(parse_cluster("ni"), ClusterResult::Boolean(Some("ni".to_string()))); + } + + #[test] + fn test_parse_cluster_e_no_inline() { + // -e: value-taking, empty inline → caller consumes next token + assert_eq!(parse_cluster("e"), vt(None, 'e', "")); + } + + #[test] + fn test_parse_cluster_e_inline_value() { + // -ecarrot: inline="carrot" — no r/R stripping on the value bytes + assert_eq!(parse_cluster("ecarrot"), vt(None, 'e', "carrot")); + } + + #[test] + fn test_parse_cluster_e_inline_value_no_rstrip() { + // The 'r' chars in "carrot" must survive verbatim in the inline field. + // If strip_r were called on inline bytes, this would return "caot". + let ClusterResult::ValueTaking { inline, .. } = parse_cluster("ecarrot") else { + panic!("expected ValueTaking"); + }; + assert_eq!(inline, "carrot"); + } + + #[test] + fn test_parse_cluster_g_inline_glob() { + // -g*.rs: inline="*.rs" — 'r' in "*.rs" must not be stripped + assert_eq!(parse_cluster("g*.rs"), vt(None, 'g', "*.rs")); + let ClusterResult::ValueTaking { inline, .. } = parse_cluster("g*.rs") else { + panic!("expected ValueTaking"); + }; + assert_eq!(inline, "*.rs"); + } + + #[test] + fn test_parse_cluster_rne() { + // -rne: r stripped, n in boolean prefix, e is value-taking (empty inline) + assert_eq!(parse_cluster("rne"), vt(Some("n"), 'e', "")); + } + + #[test] + fn test_parse_cluster_r_a() { + // -rA: r stripped, A is value-taking (empty inline → consume next token) + assert_eq!(parse_cluster("rA"), vt(None, 'A', "")); + } + + #[test] + fn test_parse_cluster_ni_a() { + // -niA: n and i boolean, A value-taking + assert_eq!(parse_cluster("niA"), vt(Some("ni"), 'A', "")); + } + + #[test] + fn test_parse_cluster_ai_inline() { + // -Ai: A value-taking, inline="i" (the 'i' is A's value, not a separate flag) + assert_eq!(parse_cluster("Ai"), vt(None, 'A', "i")); + } + + #[test] + fn test_parse_cluster_short_type() { + assert_eq!(parse_cluster("t"), vt(None, 't', "")); + assert_eq!(parse_cluster("tpy"), vt(None, 't', "py")); // inline type name + } + + #[test] + fn test_parse_cluster_short_max_columns() { + assert_eq!(parse_cluster("M"), vt(None, 'M', "")); + assert_eq!(parse_cluster("M120"), vt(None, 'M', "120")); + } + + // --- strip_r --- #[test] fn test_strip_r() { - // Pure r/R → None (whole cluster dropped) assert_eq!(strip_r("r"), None); assert_eq!(strip_r("R"), None); - assert_eq!(strip_r("rr"), None); assert_eq!(strip_r("rR"), None); assert_eq!(strip_r(""), None); - // Mixed → r/R removed, rest kept assert_eq!(strip_r("rn"), Some("n".to_string())); assert_eq!(strip_r("Rni"), Some("ni".to_string())); - assert_eq!(strip_r("rni"), Some("ni".to_string())); assert_eq!(strip_r("i"), Some("i".to_string())); - // Reveals the danger: strip_r must NEVER be called on value bytes. - // Calling it on "carrot" strips both 'r's and produces "caot" — this - // is exactly the original bug (-ecarrot matching "caot" instead of "carrot"). + // Shows why it must only be called on flag letters, not value bytes: assert_eq!(strip_r("carrot"), Some("caot".to_string())); } + // --- strip_recursive --- + #[test] fn test_strip_recursive() { assert_eq!(strip_recursive("--recursive"), None); From 307b5573838d24cc59191c12422ef1f216b9a087 Mon Sep 17 00:00:00 2001 From: Nicolas Le Cam Date: Sun, 14 Jun 2026 15:31:20 +0200 Subject: [PATCH 12/50] feat(grep): sort content alphabetically --- src/cmds/system/grep_cmd.rs | 62 ++++++++++++++++++++++--------------- 1 file changed, 37 insertions(+), 25 deletions(-) diff --git a/src/cmds/system/grep_cmd.rs b/src/cmds/system/grep_cmd.rs index e7639e1f36..a7d7150564 100644 --- a/src/cmds/system/grep_cmd.rs +++ b/src/cmds/system/grep_cmd.rs @@ -13,42 +13,42 @@ use std::collections::HashMap; /// Includes all rg short flags that take a value argument except `-e` and `-r` /// (stripped) and `-E` (dialect, left to #2138). Failure mode for a missing /// entry: the value becomes a positional (visible wrong result, not silent). -const VALUE_FLAGS_SHORT: &[u8] = b"ABCgfjmtTdM"; +const VALUE_FLAGS_SHORT: &[u8] = b"ABCMTdfgjmt"; /// Long flags that consume the NEXT token as their value (space-separated form). /// Inline `=` form (`--flag=value`) is one token and passes through unchanged. /// `--regexp` is handled separately (its value goes to `patterns`). /// `--encoding` value is consumed correctly here; dialect routing is #2138's job. const VALUE_FLAGS_LONG: &[&str] = &[ + "--after-context", + "--before-context", + "--color", + "--colors", + "--context", + "--context-separator", + "--encoding", + "--engine", + "--field-context-separator", + "--field-match-separator", "--file", "--glob", "--iglob", - "--type", - "--type-not", - "--type-add", - "--type-clear", + "--ignore-file", + "--max-columns", "--max-count", "--max-depth", "--max-filesize", - "--max-columns", - "--after-context", - "--before-context", - "--context", - "--encoding", - "--engine", + "--path-separator", + "--pre", + "--pre-glob", + "--replace", "--sort", "--sortr", "--threads", - "--replace", - "--pre", - "--pre-glob", - "--path-separator", - "--ignore-file", - "--field-match-separator", - "--field-context-separator", - "--context-separator", - "--color", - "--colors", + "--type", + "--type-add", + "--type-clear", + "--type-not", ]; /// Result of parsing the content of a short flag cluster (the part after `-`). @@ -629,10 +629,22 @@ mod tests { assert_eq!(parse_cluster("r"), ClusterResult::Boolean(None)); assert_eq!(parse_cluster("R"), ClusterResult::Boolean(None)); assert_eq!(parse_cluster("rR"), ClusterResult::Boolean(None)); - assert_eq!(parse_cluster("rn"), ClusterResult::Boolean(Some("n".to_string()))); - assert_eq!(parse_cluster("Rni"), ClusterResult::Boolean(Some("ni".to_string()))); - assert_eq!(parse_cluster("n"), ClusterResult::Boolean(Some("n".to_string()))); - assert_eq!(parse_cluster("ni"), ClusterResult::Boolean(Some("ni".to_string()))); + assert_eq!( + parse_cluster("rn"), + ClusterResult::Boolean(Some("n".to_string())) + ); + assert_eq!( + parse_cluster("Rni"), + ClusterResult::Boolean(Some("ni".to_string())) + ); + assert_eq!( + parse_cluster("n"), + ClusterResult::Boolean(Some("n".to_string())) + ); + assert_eq!( + parse_cluster("ni"), + ClusterResult::Boolean(Some("ni".to_string())) + ); } #[test] From abe7d4210e0fe5c0b9322ec5210c6a6aadaa3db5 Mon Sep 17 00:00:00 2001 From: vegetablechicken233 <25945262+vegetablechicken233@users.noreply.github.com> Date: Mon, 15 Jun 2026 17:30:50 +0800 Subject: [PATCH 13/50] fix(grep): use portable --null in system grep fallback (BSD/macOS) The system-grep fallback (used when ripgrep is not installed) passed -rnHZ, relying on -Z for the NUL filename separator the match parser requires. -Z only means --null on GNU grep; on BSD/macOS grep it is an alias for --decompress (zgrep mode), so output is plain file:line:content with no NUL. parse_match_line() then matches zero filenames and every result collapses into "N matches in 0 files" with all lines hidden behind [+N more]. Use the long option --null instead, which both GNU and BSD grep define as "print a zero-byte after the file name". Related to #2310 Co-Authored-By: Claude Fable 5 --- src/cmds/system/grep_cmd.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/cmds/system/grep_cmd.rs b/src/cmds/system/grep_cmd.rs index a7d7150564..072ebc28d7 100644 --- a/src/cmds/system/grep_cmd.rs +++ b/src/cmds/system/grep_cmd.rs @@ -349,7 +349,9 @@ pub fn run( for p in &patterns { grep_cmd.args(["-e", p]); } - grep_cmd.args(["-rnHZ", "--"]); + // --null (not -Z): on BSD/macOS grep -Z means --decompress, not the + // NUL filename separator parse_match_line() needs (issue #2310). + grep_cmd.args(["-rnH", "--null", "--"]); grep_cmd.args(&paths); exec_capture(&mut grep_cmd) }) @@ -465,8 +467,9 @@ pub fn run( /// Parses a single rg/grep match line of the form `file\0line_number:content`. /// -/// Requires the underlying command to be invoked with `-0` (rg) or `-Z` (grep) -/// so the filename is NUL-separated from `line:content`. NUL cannot appear in +/// Requires the underlying command to be invoked with `-0` (rg) or `--null` +/// (grep) so the filename is NUL-separated from `line:content`. NUL cannot +/// appear in /// file paths, so the parser is unambiguous regardless of: /// - content with `:` or `::` (e.g. `ClassRegistry::init(...)`, issue #1436); /// - paths with embedded `:` (Windows drive letters, weird filenames like From c7f493b0cd0fbe9ed1d2da8ea66f86f2ca26cd01 Mon Sep 17 00:00:00 2001 From: Lawton Mizell Date: Wed, 3 Jun 2026 10:53:52 -0400 Subject: [PATCH 14/50] feat(oc): add Openshift CLI support with shared k8s filtering --- README.md | 3 + src/cmds/cloud/README.md | 2 +- src/cmds/cloud/container.rs | 103 ++++++--- src/discover/rules.rs | 9 + src/main.rs | 125 ++++++++-- tests/fixtures/oc_pods.json | 450 ++++++++++++++++++++++++++++++++++++ 6 files changed, 634 insertions(+), 58 deletions(-) create mode 100644 tests/fixtures/oc_pods.json diff --git a/README.md b/README.md index 4e224cbe57..16988beead 100644 --- a/README.md +++ b/README.md @@ -230,6 +230,9 @@ rtk docker compose ps # Compose services rtk kubectl pods # Compact pod list rtk kubectl logs # Deduplicated logs rtk kubectl services # Compact service list +rtk oc get pods # OpenShift pod summary +rtk oc get services # OpenShift service list +rtk oc logs # Deduplicated logs ``` ### Data & Analytics diff --git a/src/cmds/cloud/README.md b/src/cmds/cloud/README.md index a86acec6ad..6498f7c58e 100644 --- a/src/cmds/cloud/README.md +++ b/src/cmds/cloud/README.md @@ -5,7 +5,7 @@ ## Specifics - `aws_cmd.rs` — 25 specialized filters covering STS, S3, EC2, ECS, RDS, CloudFormation, CloudWatch Logs, Lambda, IAM, DynamoDB, EKS, SQS, Secrets Manager. Forces `--output json` for structured parsing, uses `force_tee_hint()` for truncation recovery, strips Lambda secrets. Shared runner `run_aws_filtered()` handles boilerplate for JSON-based filters; text-based filters (S3 ls, S3 sync/cp) have dedicated runners -- `container.rs` handles both Docker and Kubernetes; `DockerCommands` and `KubectlCommands` sub-enums in `main.rs` route to `container::run()` -- uses passthrough for unknown subcommands +- `container.rs` handles Docker, Kubernetes, and OpenShift; `DockerCommands`, `KubectlCommands`, and `OcCommands` sub-enums in `main.rs` route to `container::run()` -- uses passthrough for unknown subcommands - `curl_cmd.rs` truncates long responses, saves full output to file for recovery - `wget_cmd.rs` wraps wget with output filtering - `psql_cmd.rs` filters PostgreSQL query output diff --git a/src/cmds/cloud/container.rs b/src/cmds/cloud/container.rs index 2738cf29e0..7b26683108 100644 --- a/src/cmds/cloud/container.rs +++ b/src/cmds/cloud/container.rs @@ -27,24 +27,24 @@ pub fn run(cmd: ContainerCmd, args: &[String], verbose: u8) -> Result { ContainerCmd::DockerPsAll => docker_ps_all(verbose), ContainerCmd::DockerImages => docker_images(verbose), ContainerCmd::DockerLogs => docker_logs(args, verbose), - ContainerCmd::KubectlPods => kubectl_pods(args, verbose), - ContainerCmd::KubectlServices => kubectl_services(args, verbose), - ContainerCmd::KubectlLogs => kubectl_logs(args, verbose), + ContainerCmd::KubectlPods => k8s_pods("kubectl", args, verbose), + ContainerCmd::KubectlServices => k8s_services("kubectl", args, verbose), + ContainerCmd::KubectlLogs => k8s_logs("kubectl", args, verbose), } } -fn run_kubectl_json(cmd: Command, label: &str, filter_fn: F) -> Result +fn run_k8s_json(cmd: Command, tool: &str, label: &str, filter_fn: F) -> Result where F: Fn(&Value) -> String, { runner::run_filtered( cmd, - "kubectl", + tool, label, |stdout| match serde_json::from_str::(stdout) { Ok(json) => filter_fn(&json), Err(e) => { - eprintln!("[rtk] kubectl: JSON parse failed: {}", e); + eprintln!("[rtk] {}: JSON parse failed: {}", tool, e); stdout.to_string() } }, @@ -330,13 +330,13 @@ fn docker_logs(args: &[String], _verbose: u8) -> Result { ) } -fn kubectl_pods(args: &[String], _verbose: u8) -> Result { - let mut cmd = resolved_command("kubectl"); +pub fn k8s_pods(tool: &str, args: &[String], _verbose: u8) -> Result { + let mut cmd = resolved_command(tool); cmd.args(["get", "pods", "-o", "json"]); for arg in args { cmd.arg(arg); } - run_kubectl_json(cmd, "get pods", format_kubectl_pods) + run_k8s_json(cmd, tool, "get pods", format_kubectl_pods) } fn format_kubectl_pods(json: &Value) -> String { @@ -416,13 +416,13 @@ fn format_kubectl_pods(json: &Value) -> String { out } -fn kubectl_services(args: &[String], _verbose: u8) -> Result { - let mut cmd = resolved_command("kubectl"); +pub fn k8s_services(tool: &str, args: &[String], _verbose: u8) -> Result { + let mut cmd = resolved_command(tool); cmd.args(["get", "services", "-o", "json"]); for arg in args { cmd.arg(arg); } - run_kubectl_json(cmd, "get services", format_kubectl_services) + run_k8s_json(cmd, tool, "get services", format_kubectl_services) } fn format_kubectl_services(json: &Value) -> String { @@ -477,14 +477,14 @@ fn format_kubectl_services(json: &Value) -> String { out } -fn kubectl_logs(args: &[String], _verbose: u8) -> Result { +pub fn k8s_logs(tool: &str, args: &[String], _verbose: u8) -> Result { let pod = args.first().map(|s| s.as_str()).unwrap_or(""); if pod.is_empty() { - println!("Usage: rtk kubectl logs "); + println!("Usage: rtk {} logs ", tool); return Ok(0); } - let mut cmd = resolved_command("kubectl"); + let mut cmd = resolved_command(tool); cmd.args(["logs", "--tail", "100", pod]); for arg in args.iter().skip(1) { cmd.arg(arg); @@ -493,7 +493,7 @@ fn kubectl_logs(args: &[String], _verbose: u8) -> Result { let label = format!("logs {}", pod); runner::run_filtered( cmd, - "kubectl", + tool, &label, |stdout| { format!( @@ -751,17 +751,26 @@ pub fn run_compose_passthrough(args: &[OsString], verbose: u8) -> Result { } pub fn run_kubectl_get(args: &[String], verbose: u8) -> Result { - match kubectl_get_target(args) { - Some(("pods", rest)) => run(ContainerCmd::KubectlPods, rest, verbose), - Some(("services", rest)) => run(ContainerCmd::KubectlServices, rest, verbose), - _ => run_kubectl_get_passthrough(args, verbose), + run_k8s_get("kubectl", args, verbose) +} + +fn run_k8s_get(tool: &str, args: &[String], verbose: u8) -> Result { + match k8s_get_target(args) { + Some(("pods", rest)) => k8s_pods(tool, rest, verbose), + Some(("services", rest)) => k8s_services(tool, rest, verbose), + _ => { + let passthrough_args: Vec = std::iter::once(OsString::from("get")) + .chain(args.iter().map(|arg| OsString::from(arg.as_str()))) + .collect(); + crate::core::runner::run_passthrough(tool, &passthrough_args, verbose) + } } } -fn kubectl_get_target(args: &[String]) -> Option<(&'static str, &[String])> { +fn k8s_get_target(args: &[String]) -> Option<(&'static str, &[String])> { let resource = args.first()?.as_str(); let rest = &args[1..]; - if kubectl_get_requests_raw_output(rest) { + if k8s_get_requests_raw_output(rest) { return None; } @@ -772,7 +781,7 @@ fn kubectl_get_target(args: &[String]) -> Option<(&'static str, &[String])> { } } -fn kubectl_get_requests_raw_output(args: &[String]) -> bool { +fn k8s_get_requests_raw_output(args: &[String]) -> bool { args.iter().any(|arg| { matches!( arg.as_str(), @@ -782,17 +791,18 @@ fn kubectl_get_requests_raw_output(args: &[String]) -> bool { }) } -fn run_kubectl_get_passthrough(args: &[String], verbose: u8) -> Result { - let passthrough_args: Vec = std::iter::once(OsString::from("get")) - .chain(args.iter().map(|arg| OsString::from(arg.as_str()))) - .collect(); - run_kubectl_passthrough(&passthrough_args, verbose) -} - pub fn run_kubectl_passthrough(args: &[OsString], verbose: u8) -> Result { crate::core::runner::run_passthrough("kubectl", args, verbose) } +pub fn run_oc_get(args: &[String], verbose: u8) -> Result { + run_k8s_get("oc", args, verbose) +} + +pub fn run_oc_passthrough(args: &[OsString], verbose: u8) -> Result { + crate::core::runner::run_passthrough("oc", args, verbose) +} + #[cfg(test)] mod tests { use super::*; @@ -931,12 +941,12 @@ api-1 | Connected to database"; } #[test] - fn test_kubectl_get_target_pods_aliases() { + fn test_k8s_get_target_pods_aliases() { for resource in ["po", "pod", "pods"] { let args = vec![resource.to_string(), "-n".to_string(), "default".to_string()]; assert_eq!( - kubectl_get_target(&args), + k8s_get_target(&args), Some(("pods", &args[1..])), "failed for {resource}" ); @@ -944,12 +954,12 @@ api-1 | Connected to database"; } #[test] - fn test_kubectl_get_target_services_aliases() { + fn test_k8s_get_target_services_aliases() { for resource in ["svc", "service", "services"] { let args = vec![resource.to_string(), "-A".to_string()]; assert_eq!( - kubectl_get_target(&args), + k8s_get_target(&args), Some(("services", &args[1..])), "failed for {resource}" ); @@ -957,14 +967,14 @@ api-1 | Connected to database"; } #[test] - fn test_kubectl_get_target_unsupported_resource() { + fn test_k8s_get_target_unsupported_resource() { let args = vec!["deployments".to_string()]; - assert_eq!(kubectl_get_target(&args), None); + assert_eq!(k8s_get_target(&args), None); } #[test] - fn test_kubectl_get_target_respects_output_flags() { + fn test_k8s_get_target_respects_output_flags() { for output_flag in ["-o", "-owide", "--output", "--output=json"] { let args = vec![ "pods".to_string(), @@ -973,10 +983,27 @@ api-1 | Connected to database"; ]; assert_eq!( - kubectl_get_target(&args), + k8s_get_target(&args), None, "should pass through {output_flag}" ); } } + + // ── oc support ──────────────────────────────────────── + + #[test] + fn test_oc_pods_savings() { + let input_str = include_str!("../../../tests/fixtures/oc_pods.json"); + let input: Value = serde_json::from_str(input_str).expect("fixture should parse"); + let output = format_kubectl_pods(&input); + let input_tokens = input_str.split_whitespace().count(); + let output_tokens = output.split_whitespace().count(); + let savings = 100.0 - (output_tokens as f64 / input_tokens as f64 * 100.0); + assert!( + savings >= 60.0, + "Expected >=60% savings, got {:.1}%", + savings + ); + } } diff --git a/src/discover/rules.rs b/src/discover/rules.rs index 6bb5741c8e..78d5e00585 100644 --- a/src/discover/rules.rs +++ b/src/discover/rules.rs @@ -389,6 +389,15 @@ pub const RULES: &[RtkRule] = &[ subcmd_savings: &[], subcmd_status: &[], }, + RtkRule { + pattern: r"^oc\s+(get|logs|describe|apply|status|adm)", + rtk_cmd: "rtk oc", + rewrite_prefixes: &["oc"], + category: "Infra", + savings_pct: 85.0, + subcmd_savings: &[], + subcmd_status: &[], + }, RtkRule { pattern: r"^tree(\s|$)", rtk_cmd: "rtk tree", diff --git a/src/main.rs b/src/main.rs index 066ebecd33..114c78714e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -293,6 +293,12 @@ enum Commands { command: KubectlCommands, }, + /// OpenShift CLI (oc) commands with compact output + Oc { + #[command(subcommand)] + command: OcCommands, + }, + /// Run command and show heuristic summary Summary { /// Command to run and summarize @@ -982,6 +988,41 @@ enum KubectlCommands { Other(Vec), } +#[derive(Debug, Subcommand)] +enum OcCommands { + /// Get OpenShift resources (compact for pods/services) + Get { + /// oc get arguments + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// List pods + Pods { + #[arg(short, long)] + namespace: Option, + /// All namespaces + #[arg(short = 'A', long)] + all: bool, + }, + /// List services + Services { + #[arg(short, long)] + namespace: Option, + /// All namespaces + #[arg(short = 'A', long)] + all: bool, + }, + /// Show pod logs (deduplicated) + Logs { + pod: String, + #[arg(short, long)] + container: Option, + }, + /// Passthrough: runs any unsupported oc subcommand directly + #[command(external_subcommand)] + Other(Vec), +} + #[derive(Debug, Subcommand)] enum PrismaCommands { /// Generate Prisma Client (strip ASCII art) @@ -1314,6 +1355,26 @@ fn shell_split(input: &str) -> Vec { discover::lexer::shell_split(input) } +fn build_k8s_namespace_args(namespace: Option, all: bool) -> Vec { + let mut args = Vec::new(); + if all { + args.push("-A".to_string()); + } else if let Some(n) = namespace { + args.push("-n".to_string()); + args.push(n); + } + args +} + +fn build_k8s_logs_args(pod: String, container: Option) -> Vec { + let mut args = vec![pod]; + if let Some(cont) = container { + args.push("-c".to_string()); + args.push(cont); + } + args +} + /// Merge pnpm global filters args with other ones for standard String-based commands fn merge_pnpm_args(filters: &[String], args: &[String]) -> Vec { filters @@ -1756,36 +1817,37 @@ fn run_cli() -> Result { Commands::Kubectl { command } => match command { KubectlCommands::Get { args } => container::run_kubectl_get(&args, cli.verbose)?, KubectlCommands::Pods { namespace, all } => { - let mut args: Vec = Vec::new(); - if all { - args.push("-A".to_string()); - } else if let Some(n) = namespace { - args.push("-n".to_string()); - args.push(n); - } + let args = build_k8s_namespace_args(namespace, all); container::run(container::ContainerCmd::KubectlPods, &args, cli.verbose)? } KubectlCommands::Services { namespace, all } => { - let mut args: Vec = Vec::new(); - if all { - args.push("-A".to_string()); - } else if let Some(n) = namespace { - args.push("-n".to_string()); - args.push(n); - } + let args = build_k8s_namespace_args(namespace, all); container::run(container::ContainerCmd::KubectlServices, &args, cli.verbose)? } KubectlCommands::Logs { pod, container: c } => { - let mut args = vec![pod]; - if let Some(cont) = c { - args.push("-c".to_string()); - args.push(cont); - } + let args = build_k8s_logs_args(pod, c); container::run(container::ContainerCmd::KubectlLogs, &args, cli.verbose)? } KubectlCommands::Other(args) => container::run_kubectl_passthrough(&args, cli.verbose)?, }, + Commands::Oc { command } => match command { + OcCommands::Get { args } => container::run_oc_get(&args, cli.verbose)?, + OcCommands::Pods { namespace, all } => { + let args = build_k8s_namespace_args(namespace, all); + container::k8s_pods("oc", &args, cli.verbose)? + } + OcCommands::Services { namespace, all } => { + let args = build_k8s_namespace_args(namespace, all); + container::k8s_services("oc", &args, cli.verbose)? + } + OcCommands::Logs { pod, container: c } => { + let args = build_k8s_logs_args(pod, c); + container::k8s_logs("oc", &args, cli.verbose)? + } + OcCommands::Other(args) => container::run_oc_passthrough(&args, cli.verbose)?, + }, + Commands::Summary { command } => { let cmd = command.join(" "); summary::run(&cmd, cli.verbose)? @@ -2504,6 +2566,7 @@ fn is_operational_command(cmd: &Commands) -> bool { | Commands::Dotnet { .. } | Commands::Docker { .. } | Commands::Kubectl { .. } + | Commands::Oc { .. } | Commands::Summary { .. } | Commands::Grep { .. } | Commands::Wget { .. } @@ -2692,6 +2755,30 @@ mod tests { } } + #[test] + fn test_try_parse_oc_get() { + let cli = Cli::try_parse_from(["rtk", "oc", "get", "pods", "-n", "default"]).unwrap(); + + match cli.command { + Commands::Oc { + command: OcCommands::Get { args }, + } => assert_eq!(args, vec!["pods", "-n", "default"]), + _ => panic!("Expected Oc Get command"), + } + } + + #[test] + fn test_try_parse_oc_other() { + let cli = Cli::try_parse_from(["rtk", "oc", "new-project", "test"]).unwrap(); + + match cli.command { + Commands::Oc { + command: OcCommands::Other(_), + } => {} + _ => panic!("Expected Oc Other command"), + } + } + #[test] fn test_try_parse_init_agent_hermes_uninstall() { let cli = Cli::try_parse_from(["rtk", "init", "--agent", "hermes", "--uninstall"]).unwrap(); diff --git a/tests/fixtures/oc_pods.json b/tests/fixtures/oc_pods.json new file mode 100644 index 0000000000..01b008d8c3 --- /dev/null +++ b/tests/fixtures/oc_pods.json @@ -0,0 +1,450 @@ +{ + "apiVersion": "v1", + "kind": "List", + "metadata": { + "resourceVersion": "" + }, + "items": [ + { + "metadata": { + "name": "alertmanager-main-0", + "namespace": "openshift-monitoring" + }, + "status": { + "phase": "Running", + "containerStatuses": [ + { + "name": "alertmanager", + "restartCount": 0 + }, + { + "name": "config-reloader", + "restartCount": 0 + }, + { + "name": "kube-rbac-proxy", + "restartCount": 0 + }, + { + "name": "kube-rbac-proxy-metric", + "restartCount": 0 + }, + { + "name": "kube-rbac-proxy-web", + "restartCount": 0 + }, + { + "name": "prom-label-proxy", + "restartCount": 0 + } + ] + } + }, + { + "metadata": { + "name": "alertmanager-main-1", + "namespace": "openshift-monitoring" + }, + "status": { + "phase": "Running", + "containerStatuses": [ + { + "name": "alertmanager", + "restartCount": 0 + }, + { + "name": "config-reloader", + "restartCount": 0 + }, + { + "name": "kube-rbac-proxy", + "restartCount": 0 + }, + { + "name": "kube-rbac-proxy-metric", + "restartCount": 0 + }, + { + "name": "kube-rbac-proxy-web", + "restartCount": 0 + }, + { + "name": "prom-label-proxy", + "restartCount": 0 + } + ] + } + }, + { + "metadata": { + "name": "cluster-monitoring-operator-7fb9fd4c5d-wm2k6", + "namespace": "openshift-monitoring" + }, + "status": { + "phase": "Running", + "containerStatuses": [ + { + "name": "cluster-monitoring-operator", + "restartCount": 0 + } + ] + } + }, + { + "metadata": { + "name": "kube-state-metrics-75586fdddb-5zw6b", + "namespace": "openshift-monitoring" + }, + "status": { + "phase": "Running", + "containerStatuses": [ + { + "name": "kube-rbac-proxy-main", + "restartCount": 0 + }, + { + "name": "kube-rbac-proxy-self", + "restartCount": 0 + }, + { + "name": "kube-state-metrics", + "restartCount": 0 + } + ] + } + }, + { + "metadata": { + "name": "metrics-server-fdfb77995-jmqnm", + "namespace": "openshift-monitoring" + }, + "status": { + "phase": "Running", + "containerStatuses": [ + { + "name": "metrics-server", + "restartCount": 0 + } + ] + } + }, + { + "metadata": { + "name": "metrics-server-fdfb77995-w4r8p", + "namespace": "openshift-monitoring" + }, + "status": { + "phase": "Running", + "containerStatuses": [ + { + "name": "metrics-server", + "restartCount": 0 + } + ] + } + }, + { + "metadata": { + "name": "monitoring-plugin-85b45d978c-27rfm", + "namespace": "openshift-monitoring" + }, + "status": { + "phase": "Running", + "containerStatuses": [ + { + "name": "monitoring-plugin", + "restartCount": 0 + } + ] + } + }, + { + "metadata": { + "name": "monitoring-plugin-85b45d978c-64g98", + "namespace": "openshift-monitoring" + }, + "status": { + "phase": "Running", + "containerStatuses": [ + { + "name": "monitoring-plugin", + "restartCount": 0 + } + ] + } + }, + { + "metadata": { + "name": "node-exporter-5fw9f", + "namespace": "openshift-monitoring" + }, + "status": { + "phase": "Running", + "containerStatuses": [ + { + "name": "kube-rbac-proxy", + "restartCount": 0 + }, + { + "name": "node-exporter", + "restartCount": 0 + } + ] + } + }, + { + "metadata": { + "name": "node-exporter-gbnrd", + "namespace": "openshift-monitoring" + }, + "status": { + "phase": "Running", + "containerStatuses": [ + { + "name": "kube-rbac-proxy", + "restartCount": 0 + }, + { + "name": "node-exporter", + "restartCount": 0 + } + ] + } + }, + { + "metadata": { + "name": "openshift-state-metrics-856bc7fff5-p8s92", + "namespace": "openshift-monitoring" + }, + "status": { + "phase": "Running", + "containerStatuses": [ + { + "name": "kube-rbac-proxy-main", + "restartCount": 0 + }, + { + "name": "kube-rbac-proxy-self", + "restartCount": 0 + }, + { + "name": "openshift-state-metrics", + "restartCount": 0 + } + ] + } + }, + { + "metadata": { + "name": "prometheus-k8s-0", + "namespace": "openshift-monitoring" + }, + "status": { + "phase": "Running", + "containerStatuses": [ + { + "name": "config-reloader", + "restartCount": 0 + }, + { + "name": "kube-rbac-proxy", + "restartCount": 0 + }, + { + "name": "kube-rbac-proxy-thanos", + "restartCount": 0 + }, + { + "name": "kube-rbac-proxy-web", + "restartCount": 0 + }, + { + "name": "prometheus", + "restartCount": 0 + }, + { + "name": "thanos-sidecar", + "restartCount": 0 + } + ] + } + }, + { + "metadata": { + "name": "prometheus-k8s-1", + "namespace": "openshift-monitoring" + }, + "status": { + "phase": "Running", + "containerStatuses": [ + { + "name": "config-reloader", + "restartCount": 0 + }, + { + "name": "kube-rbac-proxy", + "restartCount": 0 + }, + { + "name": "kube-rbac-proxy-thanos", + "restartCount": 0 + }, + { + "name": "kube-rbac-proxy-web", + "restartCount": 0 + }, + { + "name": "prometheus", + "restartCount": 0 + }, + { + "name": "thanos-sidecar", + "restartCount": 0 + } + ] + } + }, + { + "metadata": { + "name": "prometheus-operator-5978c4f47f-9zwdq", + "namespace": "openshift-monitoring" + }, + "status": { + "phase": "Running", + "containerStatuses": [ + { + "name": "kube-rbac-proxy", + "restartCount": 0 + }, + { + "name": "prometheus-operator", + "restartCount": 0 + } + ] + } + }, + { + "metadata": { + "name": "prometheus-operator-admission-webhook-6485c485f4-8cfhf", + "namespace": "openshift-monitoring" + }, + "status": { + "phase": "Running", + "containerStatuses": [ + { + "name": "prometheus-operator-admission-webhook", + "restartCount": 0 + } + ] + } + }, + { + "metadata": { + "name": "prometheus-operator-admission-webhook-6485c485f4-b655d", + "namespace": "openshift-monitoring" + }, + "status": { + "phase": "Running", + "containerStatuses": [ + { + "name": "prometheus-operator-admission-webhook", + "restartCount": 0 + } + ] + } + }, + { + "metadata": { + "name": "telemeter-client-5d75f79f95-hr22b", + "namespace": "openshift-monitoring" + }, + "status": { + "phase": "Running", + "containerStatuses": [ + { + "name": "kube-rbac-proxy", + "restartCount": 0 + }, + { + "name": "reload", + "restartCount": 0 + }, + { + "name": "telemeter-client", + "restartCount": 0 + } + ] + } + }, + { + "metadata": { + "name": "thanos-querier-5fcf655bd-4h2m2", + "namespace": "openshift-monitoring" + }, + "status": { + "phase": "Running", + "containerStatuses": [ + { + "name": "kube-rbac-proxy", + "restartCount": 0 + }, + { + "name": "kube-rbac-proxy-metrics", + "restartCount": 0 + }, + { + "name": "kube-rbac-proxy-rules", + "restartCount": 0 + }, + { + "name": "kube-rbac-proxy-web", + "restartCount": 0 + }, + { + "name": "prom-label-proxy", + "restartCount": 0 + }, + { + "name": "thanos-query", + "restartCount": 0 + } + ] + } + }, + { + "metadata": { + "name": "thanos-querier-5fcf655bd-wd28r", + "namespace": "openshift-monitoring" + }, + "status": { + "phase": "Running", + "containerStatuses": [ + { + "name": "kube-rbac-proxy", + "restartCount": 0 + }, + { + "name": "kube-rbac-proxy-metrics", + "restartCount": 0 + }, + { + "name": "kube-rbac-proxy-rules", + "restartCount": 0 + }, + { + "name": "kube-rbac-proxy-web", + "restartCount": 0 + }, + { + "name": "prom-label-proxy", + "restartCount": 0 + }, + { + "name": "thanos-query", + "restartCount": 0 + } + ] + } + } + ] +} From 34441dd4648d5f73bf40988de0c9f2c839b3e988 Mon Sep 17 00:00:00 2001 From: Jonathan Ellis Date: Thu, 18 Jun 2026 03:43:41 -0500 Subject: [PATCH 15/50] fix(hook): treat uv run as a transparent prefix --- src/discover/registry.rs | 68 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 62 insertions(+), 6 deletions(-) diff --git a/src/discover/registry.rs b/src/discover/registry.rs index 59651a497c..fc18b6be07 100644 --- a/src/discover/registry.rs +++ b/src/discover/registry.rs @@ -471,8 +471,9 @@ fn collapse_line_continuations(s: &str) -> std::borrow::Cow<'_, str> { /// being run, only *how* it's run — e.g. `docker exec mycontainer`, /// `direnv exec .`, `poetry run`, or `bundle exec`. Stripping it lets the inner /// command match a filter; the prefix is then re-prepended to the rewrite. The -/// built-in [`SHELL_PREFIX_BUILTINS`] (`noglob`, `command`, `builtin`, `exec`, -/// `nocorrect`) are always applied in addition to user-configured prefixes. +/// built-in [`BUILTIN_TRANSPARENT_PREFIXES`] (`uv run`, `noglob`, `command`, +/// `builtin`, `exec`, `nocorrect`) are always applied in addition to +/// user-configured prefixes. /// /// Matching is strict: a configured prefix `"foo bar"` matches a command that /// starts with `"foo bar "` (or strictly equals `"foo bar"`), not anything @@ -650,9 +651,16 @@ fn rewrite_line_range(cmd: &str) -> Option { None } -/// Shell prefix builtins that modify how the shell runs a command -/// but don't change which command runs. Strip before routing, re-prepend after. -const SHELL_PREFIX_BUILTINS: &[&str] = &["noglob", "command", "builtin", "exec", "nocorrect"]; +/// Built-in transparent wrappers that use the same strip/recurse/re-prepend +/// contract as user-configured `transparent_prefixes`. +const BUILTIN_TRANSPARENT_PREFIXES: &[&str] = &[ + "uv run", + "noglob", + "command", + "builtin", + "exec", + "nocorrect", +]; const MAX_PREFIX_DEPTH: usize = 10; @@ -752,7 +760,7 @@ fn rewrite_segment_inner( return Some(format!("{}{}", env_prefix, rewritten)); } - for &prefix in SHELL_PREFIX_BUILTINS { + for &prefix in BUILTIN_TRANSPARENT_PREFIXES { if let Some(rest) = strip_word_prefix(trimmed, prefix) { if rest.is_empty() { return None; @@ -2326,6 +2334,54 @@ mod tests { ); } + #[test] + fn test_rewrite_uv_run_pytest() { + assert_eq!( + rewrite_command_no_prefixes("uv run pytest tests/", &[]), + Some("uv run rtk pytest tests/".into()) + ); + } + + #[test] + fn test_rewrite_env_uv_run_pytest() { + assert_eq!( + rewrite_command_no_prefixes("PYTHONPATH=. uv run pytest tests/", &[]), + Some("PYTHONPATH=. uv run rtk pytest tests/".into()) + ); + } + + #[test] + fn test_rewrite_uv_run_python_m_pytest() { + assert_eq!( + rewrite_command_no_prefixes("uv run python -m pytest -q", &[]), + Some("uv run rtk pytest -q".into()) + ); + } + + #[test] + fn test_rewrite_uv_run_supported_inner_command() { + assert_eq!( + rewrite_command_no_prefixes("uv run ruff check .", &[]), + Some("uv run rtk ruff check .".into()) + ); + } + + #[test] + fn test_rewrite_uv_run_options_are_not_parsed() { + assert_eq!( + rewrite_command_no_prefixes("uv run --unknown pytest tests/", &[]), + None + ); + assert_eq!( + rewrite_command_no_prefixes("uv run -m pytest -q", &[]), + None + ); + assert_eq!( + rewrite_command_no_prefixes("uv run --module pytest -q", &[]), + None + ); + } + #[test] fn test_rewrite_pip_list() { assert_eq!( From d727db3fa1f5b90d94cd8e1b893b6b10418b42bd Mon Sep 17 00:00:00 2001 From: Ousama Ben Younes Date: Tue, 16 Jun 2026 17:10:14 +0000 Subject: [PATCH 16/50] fix(grep): surface error on exit code >= 2 instead of false "0 matches" Exit codes >= 2 signal real errors in grep/rg (bad regex, tool crash, missing binary). rtk was only checking the narrow case exit_code == 2 with non-empty stderr, letting any other error exit masquerade as a clean "0 matches" false negative. Now any exit code >= 2 surfaces the error to stderr instead of printing a false zero-match result. Fixes #2461 Co-Authored-By: Claude Opus 4.8 --- src/cmds/system/grep_cmd.rs | 20 +++++++++-- tests/grep_error_test.rs | 68 +++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 3 deletions(-) create mode 100644 tests/grep_error_test.rs diff --git a/src/cmds/system/grep_cmd.rs b/src/cmds/system/grep_cmd.rs index 072ebc28d7..a61a5fc5ff 100644 --- a/src/cmds/system/grep_cmd.rs +++ b/src/cmds/system/grep_cmd.rs @@ -386,9 +386,23 @@ pub fn run( let raw_output = result.stdout.clone(); if result.stdout.trim().is_empty() { - // Show stderr for errors (bad regex, missing file, etc.) - if exit_code == 2 && !result.stderr.trim().is_empty() { - eprintln!("{}", result.stderr.trim()); + // grep/rg convention: exit 1 = no match found (normal). + // exit >= 2 = real error (bad regex, tool crash, etc.) — surface it, + // never print "0 matches" for an error. + const GREP_ERROR_EXIT: i32 = 2; + if exit_code >= GREP_ERROR_EXIT { + if !result.stderr.trim().is_empty() { + eprintln!("{}", result.stderr.trim()); + } + let msg = format!("grep failed with exit code {}", exit_code); + timer.track( + &format!("grep -rn '{}' {}", pattern_display, path_display), + "rtk grep", + &raw_output, + &msg, + ); + eprintln!("{}", msg); + return Ok(exit_code); } let msg = format!("0 matches for '{}'", pattern_display); println!("{}", msg); diff --git a/tests/grep_error_test.rs b/tests/grep_error_test.rs new file mode 100644 index 0000000000..5e0020a98b --- /dev/null +++ b/tests/grep_error_test.rs @@ -0,0 +1,68 @@ +//! Integration test: rtk grep must NOT claim "0 matches" on error exit codes. +//! +//! grep/rg convention: exit 1 = no match (normal), exit >= 2 = real error. +//! rtk must surface the error instead of printing a false-negative "0 matches". + +use std::os::unix::fs::PermissionsExt; +use std::process::Command; + +#[test] +fn grep_error_exit_code_no_false_negative() { + let dir = std::env::temp_dir().join(format!("rtk-test-grep-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("create temp dir"); + + // Test file with known content so "0 matches" is provably wrong + let test_file = dir.join("test.txt"); + std::fs::write(&test_file, "hello world\n").expect("write test file"); + + // Fake rg that exits with code 2 (error), empty output — simulates + // a broken/missing rg binary or a ripgrep panic. + let fake_rg = dir.join("rg"); + std::fs::write(&fake_rg, "#!/bin/sh\nexit 2\n").expect("write fake rg"); + std::fs::set_permissions(&fake_rg, std::fs::Permissions::from_mode(0o755)) + .expect("chmod fake rg"); + + // Reference the rtk binary built by cargo for integration tests + let rtk = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join(if cfg!(debug_assertions) { + "target/debug/rtk" + } else { + "target/release/rtk" + }); + + let path_env = format!( + "{}:{}", + dir.display(), + std::env::var("PATH").unwrap_or_default() + ); + + let output = Command::new(&rtk) + .args(["grep", "hello"]) + .arg(test_file.to_str().unwrap()) + .env("PATH", &path_env) + .env("RTK_HOOK_OFF", "1") // no hook inference + .output() + .expect("run rtk"); + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert!( + !stdout.contains("0 matches"), + "ERROR exit MUST NOT claim '0 matches' (false negative).\nstdout: {}\nstderr: {}", + stdout, + stderr, + ); + + // Error exit code should be non-zero (propagated from rg's exit 2) + assert_ne!( + output.status.code().unwrap_or(0), + 0, + "rtk should exit non-zero on grep error.\nstdout: {}\nstderr: {}", + stdout, + stderr, + ); + + // Cleanup + let _ = std::fs::remove_dir_all(&dir); +} From 99969e02e2d2dd5188e1bd4eab5af565e1cace74 Mon Sep 17 00:00:00 2001 From: Ousama Ben Younes Date: Wed, 17 Jun 2026 22:26:11 +0000 Subject: [PATCH 17/50] test(grep): gate grep_error_test with #![cfg(unix)] for Windows CI The test simulates a broken `rg` via a chmod'd shell script (from_mode, PermissionsExt) which is unix-only. The unconditional `use std::os::unix` broke compilation of the integration-test crate on the Windows CI target. Gate the whole file so Windows stays green while preserving unix coverage. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/grep_error_test.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/grep_error_test.rs b/tests/grep_error_test.rs index 5e0020a98b..a67871970d 100644 --- a/tests/grep_error_test.rs +++ b/tests/grep_error_test.rs @@ -2,6 +2,10 @@ //! //! grep/rg convention: exit 1 = no match (normal), exit >= 2 = real error. //! rtk must surface the error instead of printing a false-negative "0 matches". +//! +//! Unix-only: simulates a broken `rg` via a chmod'd shell script. The whole +//! file is gated so the integration-test crate still compiles on Windows CI. +#![cfg(unix)] use std::os::unix::fs::PermissionsExt; use std::process::Command; From fa7e5102a3b8214ff1af3c61ca53aff89b9f3dac Mon Sep 17 00:00:00 2001 From: Ousama Ben Younes Date: Wed, 17 Jun 2026 22:29:35 +0000 Subject: [PATCH 18/50] style(test): cargo fmt grep_error_test.rs Wrap the rtk binary path expression per rustfmt to satisfy `cargo fmt --all -- --check` on CI. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/grep_error_test.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/grep_error_test.rs b/tests/grep_error_test.rs index a67871970d..015735e37d 100644 --- a/tests/grep_error_test.rs +++ b/tests/grep_error_test.rs @@ -27,8 +27,8 @@ fn grep_error_exit_code_no_false_negative() { .expect("chmod fake rg"); // Reference the rtk binary built by cargo for integration tests - let rtk = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join(if cfg!(debug_assertions) { + let rtk = + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(if cfg!(debug_assertions) { "target/debug/rtk" } else { "target/release/rtk" From 3572d11e267ba0f9d164c9c59b8b074131b9915a Mon Sep 17 00:00:00 2001 From: Ousama Ben Younes Date: Thu, 18 Jun 2026 09:37:00 +0000 Subject: [PATCH 19/50] refactor(grep): extract error-exit decision into pure unit-tested fn Address KuSh review on #2465: - Move the `exit_code >= 2` decision into a pure `is_grep_error_exit` function and unit-test it directly (0/1 = normal, >=2 = error), instead of faking the rg binary in an integration test. - Drop the GREP_ERROR_EXIT const; the doc comment on the function conveys the grep/rg exit convention. - Remove tests/grep_error_test.rs (faked binary) in favour of the unit test. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cmds/system/grep_cmd.rs | 25 ++++++++++--- tests/grep_error_test.rs | 72 ------------------------------------- 2 files changed, 20 insertions(+), 77 deletions(-) delete mode 100644 tests/grep_error_test.rs diff --git a/src/cmds/system/grep_cmd.rs b/src/cmds/system/grep_cmd.rs index a61a5fc5ff..b3954526a1 100644 --- a/src/cmds/system/grep_cmd.rs +++ b/src/cmds/system/grep_cmd.rs @@ -386,11 +386,7 @@ pub fn run( let raw_output = result.stdout.clone(); if result.stdout.trim().is_empty() { - // grep/rg convention: exit 1 = no match found (normal). - // exit >= 2 = real error (bad regex, tool crash, etc.) — surface it, - // never print "0 matches" for an error. - const GREP_ERROR_EXIT: i32 = 2; - if exit_code >= GREP_ERROR_EXIT { + if is_grep_error_exit(exit_code) { if !result.stderr.trim().is_empty() { eprintln!("{}", result.stderr.trim()); } @@ -587,10 +583,29 @@ fn compact_path(path: &str) -> String { ) } +/// grep/rg convention: exit 1 = no match found (normal), exit >= 2 = real +/// error (bad regex, tool crash, missing binary). An error must surface to the +/// user, never be silently reported as a false "0 matches". +fn is_grep_error_exit(exit_code: i32) -> bool { + exit_code >= 2 +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn test_is_grep_error_exit() { + // exit 0 = matches, exit 1 = no match: both normal, not errors. + assert!(!is_grep_error_exit(0)); + assert!(!is_grep_error_exit(1)); + // exit >= 2 = real error (bad regex, tool crash, missing binary). + // Must surface, never become a false "0 matches". + assert!(is_grep_error_exit(2)); + assert!(is_grep_error_exit(3)); + assert!(is_grep_error_exit(127)); + } + #[test] fn test_clean_line() { let line = " const result = someFunction();"; diff --git a/tests/grep_error_test.rs b/tests/grep_error_test.rs deleted file mode 100644 index 015735e37d..0000000000 --- a/tests/grep_error_test.rs +++ /dev/null @@ -1,72 +0,0 @@ -//! Integration test: rtk grep must NOT claim "0 matches" on error exit codes. -//! -//! grep/rg convention: exit 1 = no match (normal), exit >= 2 = real error. -//! rtk must surface the error instead of printing a false-negative "0 matches". -//! -//! Unix-only: simulates a broken `rg` via a chmod'd shell script. The whole -//! file is gated so the integration-test crate still compiles on Windows CI. -#![cfg(unix)] - -use std::os::unix::fs::PermissionsExt; -use std::process::Command; - -#[test] -fn grep_error_exit_code_no_false_negative() { - let dir = std::env::temp_dir().join(format!("rtk-test-grep-{}", std::process::id())); - std::fs::create_dir_all(&dir).expect("create temp dir"); - - // Test file with known content so "0 matches" is provably wrong - let test_file = dir.join("test.txt"); - std::fs::write(&test_file, "hello world\n").expect("write test file"); - - // Fake rg that exits with code 2 (error), empty output — simulates - // a broken/missing rg binary or a ripgrep panic. - let fake_rg = dir.join("rg"); - std::fs::write(&fake_rg, "#!/bin/sh\nexit 2\n").expect("write fake rg"); - std::fs::set_permissions(&fake_rg, std::fs::Permissions::from_mode(0o755)) - .expect("chmod fake rg"); - - // Reference the rtk binary built by cargo for integration tests - let rtk = - std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(if cfg!(debug_assertions) { - "target/debug/rtk" - } else { - "target/release/rtk" - }); - - let path_env = format!( - "{}:{}", - dir.display(), - std::env::var("PATH").unwrap_or_default() - ); - - let output = Command::new(&rtk) - .args(["grep", "hello"]) - .arg(test_file.to_str().unwrap()) - .env("PATH", &path_env) - .env("RTK_HOOK_OFF", "1") // no hook inference - .output() - .expect("run rtk"); - - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - - assert!( - !stdout.contains("0 matches"), - "ERROR exit MUST NOT claim '0 matches' (false negative).\nstdout: {}\nstderr: {}", - stdout, - stderr, - ); - - // Error exit code should be non-zero (propagated from rg's exit 2) - assert_ne!( - output.status.code().unwrap_or(0), - 0, - "rtk should exit non-zero on grep error.\nstdout: {}\nstderr: {}", - stdout, - stderr, - ); - - // Cleanup - let _ = std::fs::remove_dir_all(&dir); -} From e36dd8cbe7cf5f37b6c67115d6d53eccc90c94ab Mon Sep 17 00:00:00 2001 From: Husam Date: Thu, 18 Jun 2026 22:22:36 +0300 Subject: [PATCH 20/50] fix(git): propagate exit code when commit fails instead of reporting ok MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run_commit had a branch that printed 'ok (nothing to commit)' and fell through to Ok(0) whenever git exited non-zero with a 'nothing to commit' message — so a no-op or hook-aborted commit was reported as success with exit 0. Gate strictly on output.status.success() via a CommitOutcome helper: any failure surfaces git's message and propagates the real exit code, matching native git. Same bug class as #1581 (push) and #1535 (stash). Fixes #2494 --- src/cmds/git/git.rs | 116 +++++++++++++++++++++++++++++++++----------- 1 file changed, 87 insertions(+), 29 deletions(-) diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index 29fd3b73ae..cbc7660a9e 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -1024,39 +1024,56 @@ fn run_commit(args: &[String], verbose: u8, global_args: &[String]) -> Result { + println!("{}", compact); + timer.track(&original_cmd, "rtk git commit", &raw_output, &compact); + Ok(0) + } + // A failed commit must never be reported as "ok". This covers "nothing + // to commit" (git exits 1) and pre-commit hook aborts: surface git's + // own message and propagate its exit code, like native git (#2494). + CommitOutcome::Failed(code) => { + eprintln!("FAILED: git commit"); + if !stderr.trim().is_empty() { + eprint!("{}", stderr); + } + if !stdout.trim().is_empty() { + eprint!("{}", stdout); + } + timer.track(&original_cmd, "rtk git commit", &raw_output, &raw_output); + Ok(code) + } + } +} - println!("{}", compact); +/// Outcome of a `git commit` invocation. A non-success status must never be +/// reported as success — the previous code printed "ok (nothing to commit)" and +/// returned 0 for a commit git had rejected (#2494). This enum makes the +/// success/failure split explicit so the exit code is always propagated. +enum CommitOutcome { + /// Commit succeeded; carries the compact one-line summary for stdout. + Ok(String), + /// Commit failed; carries git's real exit code to propagate. + Failed(i32), +} - timer.track(&original_cmd, "rtk git commit", &raw_output, &compact); - } else if stderr.contains("nothing to commit") || stdout.contains("nothing to commit") { - println!("ok (nothing to commit)"); - timer.track( - &original_cmd, - "rtk git commit", - &raw_output, - "ok (nothing to commit)", - ); +/// Classify a `git commit` result. Success extracts the compact "ok " +/// line; any failure (including "nothing to commit", which git reports with a +/// non-zero exit) propagates the real exit code instead of masquerading as ok. +fn classify_commit_outcome(success: bool, stdout: &str, exit_code: i32) -> CommitOutcome { + if success { + // Extract commit hash from output like "[main abc1234] message" or + // "[main (root-commit) abc1234] message" (incl. localized variants). + let compact = stdout + .lines() + .next() + .map(parse_commit_output) + .unwrap_or_else(|| "ok".to_string()); + CommitOutcome::Ok(compact) } else { - if !stderr.trim().is_empty() { - eprint!("{}", stderr); - } - if !stdout.trim().is_empty() { - eprint!("{}", stdout); - } - timer.track(&original_cmd, "rtk git commit", &raw_output, &raw_output); - return Ok(exit_code); + CommitOutcome::Failed(exit_code) } - - Ok(0) } // Git push progress prefixes (stderr) — dropped from the stream. @@ -2429,6 +2446,47 @@ no changes added to commit (use "git add" and/or "git commit -a") assert_eq!(parse_commit_output(""), "ok"); } + // --- commit outcome classification (issue #2494) --- + + #[test] + fn test_classify_commit_success_extracts_hash() { + match classify_commit_outcome(true, "[main abc1234def] add feature", 0) { + CommitOutcome::Ok(s) => assert_eq!(s, "ok abc1234"), + CommitOutcome::Failed(_) => panic!("successful commit must be Ok"), + } + } + + #[test] + fn test_classify_commit_success_empty_stdout() { + match classify_commit_outcome(true, "", 0) { + CommitOutcome::Ok(s) => assert_eq!(s, "ok"), + CommitOutcome::Failed(_) => panic!("successful commit must be Ok"), + } + } + + #[test] + fn test_classify_commit_nothing_to_commit_is_failure() { + // #2494: `git commit` with nothing staged exits 1. It must NOT be + // reported as ok/0 — that masked a no-op commit as success. + match classify_commit_outcome( + false, + "On branch main\nnothing to commit, working tree clean", + 1, + ) { + CommitOutcome::Failed(code) => assert_eq!(code, 1), + CommitOutcome::Ok(s) => panic!("nothing-to-commit must not be ok: {}", s), + } + } + + #[test] + fn test_classify_commit_hook_abort_propagates_exit_code() { + // A pre-commit hook abort exits non-zero; propagate it verbatim. + match classify_commit_outcome(false, "pre-commit hook failed", 2) { + CommitOutcome::Failed(code) => assert_eq!(code, 2), + CommitOutcome::Ok(_) => panic!("hook abort must be a failure"), + } + } + /// Regression test: --oneline and other user format flags must preserve all commits. /// Before fix, filter_log_output split on ---END--- which doesn't exist when /// the user specifies their own format, resulting in only 2 commits surviving. From 756c2a4ce84424a17965810392b21c7320b12678 Mon Sep 17 00:00:00 2001 From: Husam Date: Thu, 18 Jun 2026 22:53:49 +0300 Subject: [PATCH 21/50] fix(git): propagate exit code on git status failure in compact path The compact status path only caught the "not a git repository" error and let every other failure (corrupt index, lock contention, broken refs) fall through to a formatted "Clean working tree" + Ok(0), masking a real git error. The non-compact path already guards on result.success(); apply the same guard to the compact path, keeping the friendly not-a-repo message and propagating git's real exit code otherwise. Part of #2497 --- src/cmds/git/git.rs | 42 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index 29fd3b73ae..a4b16f18ed 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -862,9 +862,21 @@ fn run_status(args: &[String], verbose: u8, global_args: &[String]) -> Result Date: Thu, 18 Jun 2026 22:55:50 +0300 Subject: [PATCH 22/50] fix(git): propagate exit code on git worktree list failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run_worktree list mode never checked result.success() — a failed `git worktree list` (e.g. run outside a repo) was flattened to empty output + exit 0. The has_action branch already guards on success; apply the same guard to list mode and surface git's error with its exit code. Part of #2497 --- src/cmds/git/git.rs | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index 29fd3b73ae..c727a783de 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -1714,6 +1714,23 @@ fn run_worktree(args: &[String], verbose: u8, global_args: &[String]) -> Result< cmd.args(["worktree", "list"]); let result = exec_capture(&mut cmd).context("Failed to run git worktree list")?; + // A failed `git worktree list` (e.g. run outside a repo) must propagate, not + // be flattened to empty output + exit 0. The has_action branch above already + // guards on success (#2497). + if !result.success() { + eprintln!("FAILED: git worktree list"); + if !result.stderr.trim().is_empty() { + eprintln!("{}", result.stderr); + } + timer.track( + "git worktree list", + "rtk git worktree", + &result.stdout, + &result.stderr, + ); + return Ok(result.exit_code); + } + let filtered = filter_worktree_list(&result.stdout); println!("{}", filtered); timer.track( @@ -2045,6 +2062,16 @@ mod tests { assert!(result.contains("[feature]")); } + #[test] + fn test_run_worktree_list_propagates_failure() { + // #2497: `git worktree list` outside a repo exits non-zero; rtk must not + // report success (empty output + exit 0). + let dir = tempfile::tempdir().expect("tempdir"); + let global = vec!["-C".to_string(), dir.path().to_string_lossy().into_owned()]; + let code = run_worktree(&[], 0, &global).expect("run_worktree"); + assert_ne!(code, 0, "git worktree list failure must propagate"); + } + #[test] fn test_format_status_output_clean() { let porcelain = "## main...origin/main\n"; From 2d9dc1ab9e0d25bf7fa6b8696f8909a561dd267f Mon Sep 17 00:00:00 2001 From: Tailoo Date: Thu, 18 Jun 2026 22:46:27 +0200 Subject: [PATCH 23/50] fix(dotnet): stop duplicating failures on failing test runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On failing `dotnet test` runs the orchestrator prepended the full raw stdout ahead of the filtered summary. The `Failed Tests:` section already reproduces each failure (name + message + clipped stack) parsed from TRX/console, so every failure was printed twice — inflating output +65% vs raw, scaling linearly with failure count (issue #2501). Gate the raw-stdout prepend behind `test_needs_raw_fallback`: skip it when the structured section carries detail, keep it when the filter is blind (no failures parsed, or a parsed failure has empty detail — e.g. a self-closing with no , or a build failure / crash where nothing reaches failed_tests). Extracts two pure, unit-tested fns (`test_needs_raw_fallback`, `compose_failure_output`) so the orchestration decision is covered without running dotnet. Closes #2501 Co-Authored-By: Claude Opus 4.8 --- src/cmds/dotnet/dotnet_cmd.rs | 181 +++++++++++++++++++++++++++++----- 1 file changed, 159 insertions(+), 22 deletions(-) diff --git a/src/cmds/dotnet/dotnet_cmd.rs b/src/cmds/dotnet/dotnet_cmd.rs index f90db8b024..5105cc5006 100644 --- a/src/cmds/dotnet/dotnet_cmd.rs +++ b/src/cmds/dotnet/dotnet_cmd.rs @@ -138,7 +138,7 @@ fn run_dotnet_with_binlog(subcommand: &str, args: &[String], verbose: u8) -> Res let raw = format!("{}\n{}", result.stdout, result.stderr); let command_success = result.success(); - let filtered = match subcommand { + let (filtered, needs_raw_fallback) = match subcommand { "build" => { let binlog_summary = if should_expect_binlog && binlog_path.exists() { normalize_build_summary( @@ -151,7 +151,7 @@ fn run_dotnet_with_binlog(subcommand: &str, args: &[String], verbose: u8) -> Res let raw_summary = normalize_build_summary(binlog::parse_build_from_text(&raw), command_success); let summary = merge_build_summaries(binlog_summary, raw_summary); - format_build_output(&summary, &binlog_path) + (format_build_output(&summary, &binlog_path), true) } "test" => { // First try to parse from binlog/console output @@ -181,11 +181,18 @@ fn run_dotnet_with_binlog(subcommand: &str, args: &[String], verbose: u8) -> Res let raw_diagnostics = normalize_build_summary(binlog::parse_build_from_text(&raw), command_success); let test_build_summary = merge_build_summaries(binlog_diagnostics, raw_diagnostics); - format_test_output( - &summary, - &test_build_summary.errors, - &test_build_summary.warnings, - &binlog_path, + // The `Failed Tests:` section already carries failure detail parsed from + // TRX/console; skip the raw-stdout prepend when it would only duplicate it. + // See issue #2501. + let needs_raw = test_needs_raw_fallback(&summary); + ( + format_test_output( + &summary, + &test_build_summary.errors, + &test_build_summary.warnings, + &binlog_path, + ), + needs_raw, ) } "restore" => { @@ -203,24 +210,21 @@ fn run_dotnet_with_binlog(subcommand: &str, args: &[String], verbose: u8) -> Res let (raw_errors, raw_warnings) = binlog::parse_restore_issues_from_text(&raw); - format_restore_output(&summary, &raw_errors, &raw_warnings, &binlog_path) + ( + format_restore_output(&summary, &raw_errors, &raw_warnings, &binlog_path), + true, + ) } - _ => raw.clone(), + _ => (raw.clone(), true), }; - let output_to_print = if !command_success { - let stdout_trimmed = result.stdout.trim(); - let stderr_trimmed = result.stderr.trim(); - if !stdout_trimmed.is_empty() { - format!("{}\n\n{}", stdout_trimmed, filtered) - } else if !stderr_trimmed.is_empty() { - format!("{}\n\n{}", stderr_trimmed, filtered) - } else { - filtered - } - } else { - filtered - }; + let output_to_print = compose_failure_output( + command_success, + needs_raw_fallback, + &result.stdout, + &result.stderr, + &filtered, + ); println!("{}", output_to_print); @@ -1076,6 +1080,50 @@ fn format_build_output(summary: &binlog::BuildSummary, _binlog_path: &Path) -> S .join("\n") } +/// Decides whether the raw stdout/stderr should be prepended ahead of the +/// filtered `dotnet test` summary on a failing run. +/// +/// On failure the orchestrator can prepend the raw command output as a safety +/// net. For `test`, the filtered `Failed Tests:` section already reproduces each +/// failure (name + message + clipped stack) parsed from TRX/console, so the +/// prepend would only duplicate every failure block — the source of the +65% +/// inflation in issue #2501. +/// +/// Keep the raw fallback only when the structured section can't stand on its own: +/// no failures were parsed, or some parsed failure has no detail (filter blind). +fn test_needs_raw_fallback(summary: &binlog::TestSummary) -> bool { + summary.failed_tests.is_empty() + || summary.failed_tests.iter().any(|t| t.details.is_empty()) +} + +/// Composes the final output for a (possibly failing) run: the filtered summary, +/// optionally prefixed with raw stdout/stderr as a fallback. +/// +/// On success, or when `needs_raw_fallback` is false, only the filtered summary +/// is emitted. Otherwise the raw stdout (or stderr if stdout is empty) is +/// prepended so nothing is lost when the filter couldn't capture the failure. +fn compose_failure_output( + command_success: bool, + needs_raw_fallback: bool, + stdout: &str, + stderr: &str, + filtered: &str, +) -> String { + if command_success || !needs_raw_fallback { + return filtered.to_string(); + } + + let stdout_trimmed = stdout.trim(); + let stderr_trimmed = stderr.trim(); + if !stdout_trimmed.is_empty() { + format!("{}\n\n{}", stdout_trimmed, filtered) + } else if !stderr_trimmed.is_empty() { + format!("{}\n\n{}", stderr_trimmed, filtered) + } else { + filtered.to_string() + } +} + /// Format the test summary for stdout. /// /// `_binlog_path` is intentionally unused — the binlog is a temporary file @@ -1405,6 +1453,95 @@ mod tests { assert!(output.contains("MyTests.ShouldFail")); } + // Regression tests for issue #2501: on failing test runs the raw stdout was + // prepended in front of the filtered `Failed Tests:` section, duplicating every + // failure block (+65% vs raw). `test_needs_raw_fallback` must suppress the raw + // prepend when the structured section already carries failure detail, while + // keeping it when the filter couldn't capture the failures. + + #[test] + fn test_needs_raw_fallback_false_when_failures_have_detail() { + let summary = binlog::TestSummary { + passed: 717, + failed: 5, + skipped: 0, + total: 722, + project_count: 1, + failed_tests: vec![binlog::FailedTest { + name: "MyTests.HasRestriction".to_string(), + details: vec!["Assert.True() Failure".to_string()], + }], + duration_text: Some("2 s".to_string()), + }; + assert!(!test_needs_raw_fallback(&summary)); + } + + #[test] + fn test_needs_raw_fallback_true_when_no_failures_parsed() { + // Build failure / crash: command failed but nothing landed in failed_tests. + let summary = binlog::TestSummary { + failed: 1, + total: 1, + ..Default::default() + }; + assert!(test_needs_raw_fallback(&summary)); + } + + #[test] + fn test_needs_raw_fallback_true_when_a_failure_lacks_detail() { + // Self-closing with no : name only, no detail. + let summary = binlog::TestSummary { + failed: 1, + total: 1, + failed_tests: vec![binlog::FailedTest { + name: "MyTests.NoDetail".to_string(), + details: Vec::new(), + }], + ..Default::default() + }; + assert!(test_needs_raw_fallback(&summary)); + } + + #[test] + fn test_compose_failure_output_drops_raw_when_no_fallback_needed() { + // The raw stdout contains the inline failure; the filtered section also + // contains it. With needs_raw_fallback=false, the failure must appear once. + let raw_stdout = " failed MyTests.HasRestriction\n Assert.True() Failure"; + let filtered = + "Failed Tests:\n MyTests.HasRestriction\n Assert.True() Failure\n\nfail dotnet test: 717 passed, 5 failed"; + let output = compose_failure_output(false, false, raw_stdout, "", filtered); + + assert_eq!(output, filtered); + assert_eq!(output.matches("HasRestriction").count(), 1); + } + + #[test] + fn test_compose_failure_output_prepends_raw_when_fallback_needed() { + let raw_stdout = "Build FAILED.\n Program.cs(1,1): error CS1002"; + let filtered = "fail dotnet test: 0 passed, 1 failed"; + // command_success=false, needs_raw_fallback=true → raw is prepended. + let output = compose_failure_output(false, true, raw_stdout, "", filtered); + + assert!(output.starts_with("Build FAILED.")); + assert!(output.ends_with(filtered)); + } + + #[test] + fn test_compose_failure_output_uses_stderr_when_stdout_empty() { + let filtered = "fail dotnet test: 0 passed, 1 failed"; + let output = compose_failure_output(false, true, " ", "boom on stderr", filtered); + + assert!(output.starts_with("boom on stderr")); + assert!(output.ends_with(filtered)); + } + + #[test] + fn test_compose_failure_output_returns_filtered_on_success() { + let filtered = "ok dotnet test: 722 tests passed"; + let output = compose_failure_output(true, true, "ignored raw", "ignored", filtered); + assert_eq!(output, filtered); + } + #[test] fn test_format_test_output_surfaces_warnings() { let summary = binlog::TestSummary { From d2babe2bfb9a76dd82647e1c66fcf47affdd6317 Mon Sep 17 00:00:00 2001 From: Adrien Eppling Date: Fri, 19 Jun 2026 11:52:29 +0200 Subject: [PATCH 24/50] test(main): classify oc subcommand in PASSTHROUGH allowlist --- src/main.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main.rs b/src/main.rs index d525ecf8b1..c55751dfc0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2941,6 +2941,7 @@ mod tests { "dotnet", "docker", "kubectl", + "oc", "summary", "grep", "wget", From f9469d12cfb48a6dda2b8c0578a7d5696804ae5f Mon Sep 17 00:00:00 2001 From: xiaozh Date: Sat, 20 Jun 2026 19:31:01 +0800 Subject: [PATCH 25/50] fix(vitest): add passthrough recovery hint Explicit Vitest reporters can produce successful output that exceeds the passthrough cap. Track truncation as structured state and use force_tee_hint so the full verbose output remains recoverable. --- src/cmds/js/vitest_cmd.rs | 151 ++++++++++++++++++++++++++++++++------ 1 file changed, 129 insertions(+), 22 deletions(-) diff --git a/src/cmds/js/vitest_cmd.rs b/src/cmds/js/vitest_cmd.rs index 233eaf39cf..43b41c59b9 100644 --- a/src/cmds/js/vitest_cmd.rs +++ b/src/cmds/js/vitest_cmd.rs @@ -8,8 +8,9 @@ use crate::core::stream::exec_capture; use crate::core::tracking; use crate::core::utils::{package_manager_exec, strip_ansi}; use crate::parser::{ - emit_degradation_warning, emit_passthrough_warning, extract_json_object, truncate_passthrough, - FormatMode, OutputParser, ParseResult, TestFailure, TestResult, TokenFormatter, + emit_degradation_warning, emit_passthrough_warning, extract_json_object, truncate_output, + truncate_passthrough, FormatMode, OutputParser, ParseResult, TestFailure, TestResult, + TokenFormatter, }; use crate::Commands; @@ -246,21 +247,25 @@ pub fn run_test(command: &Commands, args: &[String], verbose: u8) -> Result let result = exec_capture(&mut cmd).context(format!("Failed to run {}", framework))?; let combined = result.combined(); - let filtered = format_test_output(framework, &result.stdout, &combined, passthrough_requested, verbose); + let filtered = format_test_output( + framework, + &result.stdout, + &combined, + passthrough_requested, + verbose, + ); + let tee_label = format!("{}_run", framework); - if let Some(hint) = - crate::core::tee::tee_and_hint(&combined, format!("{}_run", framework).as_str(), result.exit_code) - { - println!("{}\n{}", filtered, hint); - } else { - println!("{}", filtered); - } + println!( + "{}", + render_test_output(&filtered, &combined, &tee_label, result.exit_code) + ); timer.track( format!("{} run", framework).as_str(), format!("rtk {} run", framework).as_str(), &combined, - &filtered, + &filtered.text, ); if !result.success() { @@ -274,6 +279,27 @@ struct EffectiveVitestArgs { passthrough: bool, } +struct FormattedTestOutput { + text: String, + truncated: bool, +} + +impl FormattedTestOutput { + fn new(text: String) -> Self { + Self { + text, + truncated: false, + } + } + + fn truncated(text: String) -> Self { + Self { + text, + truncated: true, + } + } +} + fn build_vitest_effective_args(args: &[String]) -> EffectiveVitestArgs { let passthrough = has_explicit_vitest_reporter(args); let mut effective = vec!["run".to_string()]; @@ -310,33 +336,86 @@ fn format_test_output( combined: &str, passthrough_requested: bool, verbose: u8, -) -> String { +) -> FormattedTestOutput { if passthrough_requested { - return truncate_passthrough(combined); + return format_passthrough_output(combined); } let parse_result = VitestParser::parse(stdout); let mode = FormatMode::from_verbosity(verbose); - let filtered = match parse_result { + match parse_result { ParseResult::Full(data) => { if verbose > 0 { eprintln!("{} run (Tier 1: Full JSON parse)", framework); } - data.format(mode) + FormattedTestOutput::new(data.format(mode)) } ParseResult::Degraded(data, warnings) => { if verbose > 0 { emit_degradation_warning(framework, &warnings.join(", ")); } - data.format(mode) + FormattedTestOutput::new(data.format(mode)) } - ParseResult::Passthrough(raw) => { + ParseResult::Passthrough(_) => { emit_passthrough_warning(framework, "All parsing tiers failed"); - raw + format_passthrough_output(stdout) } + } +} + +fn format_passthrough_output(raw: &str) -> FormattedTestOutput { + let max_chars = crate::core::config::limits().passthrough_max_chars; + format_passthrough_output_with_limit(raw, max_chars) +} + +fn format_passthrough_output_with_limit(raw: &str, max_chars: usize) -> FormattedTestOutput { + let text = truncate_output(raw, max_chars); + + if raw.chars().count() > max_chars { + FormattedTestOutput::truncated(text) + } else { + FormattedTestOutput::new(text) + } +} + +fn render_test_output( + filtered: &FormattedTestOutput, + raw: &str, + tee_label: &str, + exit_code: i32, +) -> String { + render_test_output_with_hints( + filtered, + raw, + tee_label, + exit_code, + crate::core::tee::force_tee_hint, + crate::core::tee::tee_and_hint, + ) +} + +fn render_test_output_with_hints( + filtered: &FormattedTestOutput, + raw: &str, + tee_label: &str, + exit_code: i32, + force_hint: F, + tee_hint: T, +) -> String +where + F: FnOnce(&str, &str) -> Option, + T: FnOnce(&str, &str, i32) -> Option, +{ + let hint = if filtered.truncated { + force_hint(raw, tee_label) + } else { + tee_hint(raw, tee_label, exit_code) }; - filtered + match hint { + Some(hint) => format!("{}\n{}", filtered.text, hint), + None => filtered.text.clone(), + } } #[cfg(test)] @@ -500,8 +579,36 @@ Scope: all 6 workspace projects let filtered = format_test_output("vitest", output, output, true, 0); - assert!(filtered.contains("keeps docs path")); - assert!(filtered.contains("keeps app path")); - assert!(filtered.contains("Tests 2 passed")); + assert!(filtered.text.contains("keeps docs path")); + assert!(filtered.text.contains("keeps app path")); + assert!(filtered.text.contains("Tests 2 passed")); + assert!(!filtered.truncated); + } + + #[test] + fn test_vitest_explicit_reporter_truncated_output_adds_recovery_hint() { + let output = format!( + "{}\n Test Files 1 passed (1)\n Tests 80 passed (80)\n", + " ✓ constants/publicPaths.test.ts > public paths > keeps verbose case\n".repeat(80) + ); + + let filtered = format_passthrough_output_with_limit(&output, 200); + let rendered = render_test_output_with_hints( + &filtered, + &output, + "vitest_run", + 0, + |raw, label| { + assert_eq!(raw, output); + assert_eq!(label, "vitest_run"); + Some("[full output: /tmp/vitest_run.log]".to_string()) + }, + |_, _, _| Some("[full output: wrong-path.log]".to_string()), + ); + + assert!(filtered.truncated); + assert!(rendered.contains("[RTK:PASSTHROUGH] Output truncated")); + assert!(rendered.contains("[full output: /tmp/vitest_run.log]")); + assert!(!rendered.contains("wrong-path.log")); } } From 5e7eab5846cfe2de1f0d0c2a7d6c38c8de6c65e5 Mon Sep 17 00:00:00 2001 From: Tailoo Date: Sun, 21 Jun 2026 22:41:00 +0200 Subject: [PATCH 26/50] fix(dotnet): keep raw fallback when parsed failures incomplete test_needs_raw_fallback dropped the raw stdout prepend whenever every parsed failure carried detail, but never checked the list was complete against summary.failed. A run reporting 5 failures but parsing only 3 detailed blocks would silently lose the 2 unparsed failures. Add a `failed_tests.len() < summary.failed` clause so the raw fallback is kept when the structured section is numerically incomplete. Add a regression test and fix an inconsistent existing fixture (failed: 5 with a single parsed entry) that the new clause correctly flagged. Co-Authored-By: Claude Opus 4.8 --- src/cmds/dotnet/dotnet_cmd.rs | 48 +++++++++++++++++++++++++++++++---- 1 file changed, 43 insertions(+), 5 deletions(-) diff --git a/src/cmds/dotnet/dotnet_cmd.rs b/src/cmds/dotnet/dotnet_cmd.rs index 5105cc5006..232f957af6 100644 --- a/src/cmds/dotnet/dotnet_cmd.rs +++ b/src/cmds/dotnet/dotnet_cmd.rs @@ -1090,9 +1090,12 @@ fn format_build_output(summary: &binlog::BuildSummary, _binlog_path: &Path) -> S /// inflation in issue #2501. /// /// Keep the raw fallback only when the structured section can't stand on its own: -/// no failures were parsed, or some parsed failure has no detail (filter blind). +/// no failures were parsed, the parsed list is shorter than `summary.failed` +/// (some failures never made it into the section), or some parsed failure has no +/// detail (filter blind). fn test_needs_raw_fallback(summary: &binlog::TestSummary) -> bool { summary.failed_tests.is_empty() + || summary.failed_tests.len() < summary.failed || summary.failed_tests.iter().any(|t| t.details.is_empty()) } @@ -1461,21 +1464,56 @@ mod tests { #[test] fn test_needs_raw_fallback_false_when_failures_have_detail() { + // Every reported failure was parsed and carries detail: the structured + // section stands alone, so the raw prepend is dropped (issue #2501). + let failed_tests: Vec = (0..5) + .map(|i| binlog::FailedTest { + name: format!("MyTests.Case{i}"), + details: vec!["Assert.True() Failure".to_string()], + }) + .collect(); let summary = binlog::TestSummary { passed: 717, failed: 5, skipped: 0, total: 722, project_count: 1, - failed_tests: vec![binlog::FailedTest { - name: "MyTests.HasRestriction".to_string(), - details: vec!["Assert.True() Failure".to_string()], - }], + failed_tests, duration_text: Some("2 s".to_string()), }; assert!(!test_needs_raw_fallback(&summary)); } + #[test] + fn test_needs_raw_fallback_true_when_parsed_list_incomplete() { + // summary.failed reports 5, but only 3 blocks were parsed (each with + // detail). The 2 missing failures live only in raw stdout — keep the + // fallback so they aren't silently dropped. + let summary = binlog::TestSummary { + passed: 717, + failed: 5, + skipped: 0, + total: 722, + project_count: 1, + failed_tests: vec![ + binlog::FailedTest { + name: "MyTests.One".to_string(), + details: vec!["Assert.True() Failure".to_string()], + }, + binlog::FailedTest { + name: "MyTests.Two".to_string(), + details: vec!["Assert.Equal() Failure".to_string()], + }, + binlog::FailedTest { + name: "MyTests.Three".to_string(), + details: vec!["Assert.Null() Failure".to_string()], + }, + ], + duration_text: Some("2 s".to_string()), + }; + assert!(test_needs_raw_fallback(&summary)); + } + #[test] fn test_needs_raw_fallback_true_when_no_failures_parsed() { // Build failure / crash: command failed but nothing landed in failed_tests. From 1f6e36b27ef4fd2237465c97a8e73fbe4e990f61 Mon Sep 17 00:00:00 2001 From: Wladi Mitzel Date: Tue, 23 Jun 2026 07:57:18 +0200 Subject: [PATCH 27/50] fix(pulumi): keep stack identity in pulumi-stack filter Stop stripping `Current stack is X:` and `Last updated:`. pulumi stack is read-only (exit 0) so there's no tee fallback; dropping the active stack identity was irreversible signal loss. Keeping the header also fixes the orphaned indented `Managed by ` line. Owner: and Pulumi version used: remain stripped as noise. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/filters/pulumi-stack.toml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/filters/pulumi-stack.toml b/src/filters/pulumi-stack.toml index 9e8e8799d4..5518a07a25 100644 --- a/src/filters/pulumi-stack.toml +++ b/src/filters/pulumi-stack.toml @@ -8,9 +8,7 @@ match_output = [ strip_lines_matching = [ "^\\s*$", "^Please choose a stack", - "^Current stack is", "^\\s*Owner:", - "^\\s*Last updated:", "^\\s*Pulumi version used:", "^More information at:", "^Use `pulumi ", @@ -25,10 +23,11 @@ input = "No stacks found in the current workspace.\n" expected = "pulumi stack: empty" [[tests.pulumi-stack]] -name = "strips metadata noise, keeps table" +name = "keeps stack identity, strips version noise" input = """ Please choose a stack, or create a new one: Current stack is dev: + Managed by my-user Owner: my-org Last updated: 2 hours ago Pulumi version used: v3.120.0 @@ -39,7 +38,10 @@ Current stack resources (3): aws:s3:Bucket my-bucket aws:iam:Role my-role """ -expected = """Current stack resources (3): +expected = """Current stack is dev: + Managed by my-user + Last updated: 2 hours ago +Current stack resources (3): TYPE NAME pulumi:pulumi:Stack my-proj-dev aws:s3:Bucket my-bucket From 0adfae6cf61a90a98a007a6ef4a8745ab6f42a31 Mon Sep 17 00:00:00 2001 From: Adrien Eppling Date: Tue, 23 Jun 2026 13:16:27 +0200 Subject: [PATCH 28/50] fix(grep): correctly handle all flag shapes and never exceed raw output The grep argument-parsing rework routed many flag shapes through the grouping reparse, which silently dropped any line it could not parse and returned a success exit. Common invocations produced wrong or empty output -- and grep-only syntax like `--include` failed outright -- which pushed agents back to unfiltered `rtk proxy grep`. Every valid grep invocation now produces correct output: - Context (`-A`/`-B`/`-C`) is grouped and compressed instead of dropped; the header counts matches only and context lines are shown. - Output the reparse cannot represent (`-N`, `-I`, `--color`, `-p`, `--heading`, binary-file notices, ...) is passed through verbatim instead of reported as a false "0 matches". - Format/shape flags (`-c`, `-l`, `-o`, `-Z`, `--column`, `-b`, `--vimgrep`, `--null-data`) pass through cleanly with no NUL leak. - grep-only syntax (`--include`, `--exclude`, ...) works: ripgrep is the fast path and rtk falls back to system grep when ripgrep rejects a flag. - rtk grep never emits more than plain grep: if grouping is not smaller than `file:line:content`, the raw form is shown. Resolves #2543. --- src/cmds/system/grep_cmd.rs | 456 ++++++++++++++++++++++++++++-------- tests/grep_compress_test.rs | 319 +++++++++++++++++++++++++ tests/grep_context_test.rs | 111 +++++++++ 3 files changed, 786 insertions(+), 100 deletions(-) create mode 100644 tests/grep_compress_test.rs create mode 100644 tests/grep_context_test.rs diff --git a/src/cmds/system/grep_cmd.rs b/src/cmds/system/grep_cmd.rs index b3954526a1..ada13fc93d 100644 --- a/src/cmds/system/grep_cmd.rs +++ b/src/cmds/system/grep_cmd.rs @@ -1,8 +1,8 @@ //! Filters grep output by grouping matches by file. -use crate::core::stream::exec_capture; +use crate::core::stream::{exec_capture, CaptureResult}; use crate::core::tracking; -use crate::core::utils::resolved_command; +use crate::core::utils::{resolved_command, strip_ansi}; use crate::core::{args_utils, config}; use anyhow::{Context, Result}; use regex::Regex; @@ -128,6 +128,69 @@ fn strip_recursive(arg: &str) -> Option { } } +/// Ripgrep-only flags the grep fallback drops so grep doesn't abort (issue #2167). +const RG_ONLY_LONG: &[&str] = &[ + "--glob", + "--iglob", + "--type", + "--type-not", + "--type-add", + "--type-clear", + "--hidden", + "--no-ignore", + "--pcre2", + "--json", + "--stats", + "--sort", + "--sortr", + "--engine", + "--mmap", + "--no-mmap", + "--trim", + "--one-file-system", + "--max-columns", + "--max-depth", + "--max-filesize", + "--path-separator", + "--field-context-separator", + "--field-match-separator", + "--pre", + "--pre-glob", +]; + +fn strip_rg_only>(extra_args: &[T]) -> Vec<&str> { + let mut out = Vec::new(); + let mut skip_next = false; + for arg in extra_args { + let a = arg.as_ref(); + if skip_next { + skip_next = false; + continue; + } + let name = a.split('=').next().unwrap_or(a); + let rg_only = RG_ONLY_LONG.contains(&name) || a == "-g" || a == "-T"; + if rg_only { + let takes_value = a == "-g" || a == "-T" || VALUE_FLAGS_LONG.contains(&name); + if takes_value && !a.contains('=') { + skip_next = true; + } + continue; + } + out.push(a); + } + out +} + +fn has_shape_flag>(extra_args: &[T]) -> bool { + extra_args.iter().any(|arg| { + let name = arg.as_ref().split('=').next().unwrap_or(""); + matches!( + name, + "--column" | "--vimgrep" | "-b" | "--byte-offset" | "--null-data" + ) + }) +} + /// Extracts `(patterns, paths, flags)` from the raw trailing args. /// /// - `patterns`: positional pattern + all `-e`/`--regexp` values. Empty → error. @@ -251,6 +314,109 @@ fn extract_pattern_path>(args: &[T]) -> (Vec, Vec, (patterns, paths, flags) } +fn unparsed_signal(stdout: &str) -> usize { + stdout + .lines() + .filter(|line| { + let trimmed = line.trim(); + !trimmed.is_empty() && trimmed != "--" && parse_match_line(line).is_none() + }) + .count() +} + +/// Run rg; fall back to system grep when rg can't run or rejects the invocation +/// (exit 2, no output) so any grep-ism (e.g. --include) still works (#2543). +/// #2167: if grep also aborts on a flag, retry it bare. +fn grep_capture>( + rg_base: &[&str], + grep_base: &[&str], + file_type: Option<&str>, + extra_args: &[T], + patterns: &[String], + paths: &[String], +) -> Result { + let mut rg_cmd = resolved_command("rg"); + rg_cmd.args(rg_base); + if let Some(ft) = file_type { + rg_cmd.arg("--type").arg(ft); + } + rg_cmd.args(extra_args.iter().map(|a| a.as_ref())); + for p in patterns { + rg_cmd.args(["-e", &p.replace(r"\|", "|")]); + } + rg_cmd.arg("--"); + rg_cmd.args(paths); + + match exec_capture(&mut rg_cmd) { + Ok(r) if !(r.exit_code == 2 && r.stdout.is_empty()) => Ok(r), + _ => { + // --null not -Z: BSD grep -Z is --decompress (#2310); strip_rg_only (#2167). + let stripped = strip_rg_only(extra_args); + let result = run_grep_fallback(grep_base, file_type, &stripped, patterns, paths)?; + if result.exit_code == 2 && result.stdout.is_empty() && result.stderr.contains("option") { + // grep aborted on an rg-only flag that strip_rg_only missed; retry bare (#2167). + run_grep_fallback(grep_base, file_type, &[], patterns, paths) + } else { + Ok(result) + } + } + } +} + +fn run_grep_fallback( + grep_base: &[&str], + file_type: Option<&str>, + extra_args: &[&str], + patterns: &[String], + paths: &[String], +) -> Result { + let mut grep_cmd = resolved_command("grep"); + grep_cmd.args(grep_base); + // file_type cannot be translated to grep syntax; silently skip. + let _ = file_type; + grep_cmd.args(extra_args); + for p in patterns { + grep_cmd.args(["-e", p]); + } + grep_cmd.arg("--"); + grep_cmd.args(paths); + exec_capture(&mut grep_cmd).context("grep/rg failed") +} + +#[allow(clippy::too_many_arguments)] +fn passthrough>( + timer: &tracking::TimedExecution, + base: &[&str], + grep_base: &[&str], + file_type: Option<&str>, + extra_args: &[T], + patterns: &[String], + paths: &[String], + pattern_display: &str, + path_display: &str, +) -> Result { + let result = grep_capture(base, grep_base, file_type, extra_args, patterns, paths) + .context("grep/rg failed")?; + + print!("{}", strip_ansi(&result.stdout)); + if !result.stderr.is_empty() { + eprint!("{}", result.stderr.trim()); + } + + let args_display = if extra_args.is_empty() { + format!("'{}' {}", pattern_display, path_display) + } else { + let joined: Vec<&str> = extra_args.iter().map(|a| a.as_ref()).collect(); + format!("{} '{}' {}", joined.join(" "), pattern_display, path_display) + }; + + timer.track_passthrough( + &format!("grep {}", args_display), + &format!("rtk grep {} (passthrough)", args_display), + ); + Ok(result.exit_code) +} + pub fn run( max_line_len: usize, max_results: usize, @@ -312,76 +478,32 @@ pub fn run( eprintln!("grep: '{}' in {}", pattern_display, path_display); } - let mut rg_cmd = resolved_command("rg"); - // --no-ignore-vcs: match grep -r behavior (don't skip .gitignore'd files). - // Without this, rg returns 0 matches for files in .gitignore, causing - // false negatives that make AI agents draw wrong conclusions. - // Using --no-ignore-vcs (not --no-ignore) so .ignore/.rgignore are still respected. - // -H: always emit the filename. - // -0: NUL-separate filename. Allows the parser to disambiguate filenames or - // content containing `:digits:` patterns (issue #1436). - rg_cmd.args(["-nH0", "--no-heading", "--no-ignore-vcs"]); - - if let Some(ft) = file_type { - rg_cmd.arg("--type").arg(ft); - } - - // extra_args is already stripped of -r/-R/-recursive by extract_pattern_path - rg_cmd.args(&extra_args); - - // All patterns as -e flags (BRE \| → | translation for rg's PCRE engine). - // Using -e keeps `--` semantically as a flag/path separator, not part of the pattern. - for p in &patterns { - rg_cmd.args(["-e", &p.replace(r"\|", "|")]); - } - - // `--` after all flags: prevents rg from interpreting path args starting - // with `-` as its own flags. - rg_cmd.arg("--"); - rg_cmd.args(&paths); - - let result = exec_capture(&mut rg_cmd) - .or_else(|_| { - // rg unavailable: fall back to system grep with the original, - // untranslated patterns (grep interprets BRE natively). - let mut grep_cmd = resolved_command("grep"); - grep_cmd.args(&extra_args); - for p in &patterns { - grep_cmd.args(["-e", p]); - } - // --null (not -Z): on BSD/macOS grep -Z means --decompress, not the - // NUL filename separator parse_match_line() needs (issue #2310). - grep_cmd.args(["-rnH", "--null", "--"]); - grep_cmd.args(&paths); - exec_capture(&mut grep_cmd) - }) - .context("grep/rg failed")?; - - // Passthrough output flags that produce output that is already small. - if has_format_flag(&extra_args) { - print!("{}", result.stdout); - if !result.stderr.is_empty() { - eprint!("{}", result.stderr.trim()); - } - - let args_display = if extra_args.is_empty() { - format!("'{}' {}", pattern_display, path_display) - } else { - format!( - "{} '{}' {}", - extra_args.join(" "), - pattern_display, - path_display - ) - }; - - timer.track_passthrough( - &format!("grep {}", args_display), - &format!("rtk grep {} (passthrough)", args_display), + // format/shape flags: native passthrough, no -0 NUL leak (#2333). + if has_format_flag(&extra_args) || has_shape_flag(&extra_args) { + return passthrough( + &timer, + &["--no-heading", "--no-ignore-vcs"], + &["-r"], + file_type, + &extra_args, + &patterns, + &paths, + &pattern_display, + &path_display, ); - return Ok(result.exit_code); } + // GROUP path: -0 NUL-disambiguates file:line for the reparse (#1436). + let result = grep_capture( + &["-nH0", "--no-heading", "--no-ignore-vcs"], + &["-rnH", "--null"], + file_type, + &extra_args, + &patterns, + &paths, + ) + .context("grep/rg failed")?; + let exit_code = result.exit_code; let raw_output = result.stdout.clone(); @@ -411,6 +533,22 @@ pub fn run( return Ok(exit_code); } + // Safety net: unparseable shape → passthrough verbatim, never silently drop (#2333). + let signal = unparsed_signal(&raw_output); + if signal > 0 { + return passthrough( + &timer, + &["-nH", "--no-heading", "--no-ignore-vcs"], + &["-rnH"], + file_type, + &extra_args, + &patterns, + &paths, + &pattern_display, + &path_display, + ); + } + let context_re = if context_only { Regex::new(&format!( "(?i).{{0,20}}{}.*", @@ -421,17 +559,23 @@ pub fn run( None }; - let mut by_file: HashMap> = HashMap::new(); - for line in result.stdout.lines() { - let Some((file, line_num, content)) = parse_match_line(line) else { + let mut by_file: HashMap> = HashMap::new(); + for line in raw_output.lines() { + let Some((file, line_num, is_match, content)) = parse_match_line(line) else { continue; }; let cleaned = clean_line(content, max_line_len, context_re.as_ref(), &pattern_display); - by_file.entry(file).or_default().push((line_num, cleaned)); + by_file + .entry(file) + .or_default() + .push((line_num, is_match, cleaned)); } - // Derive total from parsed results so the header matches what we show. - let total_matches: usize = by_file.values().map(|v| v.len()).sum(); + let total_matches: usize = by_file + .values() + .flat_map(|v| v.iter()) + .filter(|(_, is_match, _)| *is_match) + .count(); let mut rtk_output = String::new(); rtk_output.push_str(&format!( @@ -445,56 +589,73 @@ pub fn run( files.sort_by_key(|(f, _)| *f); let per_file = config::limits().grep_max_per_file; - for (file, matches) in files { + for (file, entries) in files { if shown >= max_results { break; } let file_display = compact_path(file); - for (line_num, content) in matches.iter().take(per_file) { + for (line_num, is_match, content) in entries.iter().take(per_file) { if shown >= max_results { break; } - rtk_output.push_str(&format!("{}:{}:{}\n", file_display, line_num, content)); + if *is_match { + rtk_output.push_str(&format!("{}:{}:{}\n", file_display, line_num, content)); + } else { + rtk_output.push_str(&format!("{}-{}-{}\n", file_display, line_num, content)); + } shown += 1; } } - if total_matches > shown { - rtk_output.push_str(&format!("[+{} more]\n", total_matches - shown)); + let total_lines: usize = by_file.values().map(|v| v.len()).sum(); + if total_lines > shown { + rtk_output.push_str(&format!("[+{} more]\n", total_lines - shown)); } - print!("{}", rtk_output); + // Never-worse: show plain `file:line:content` (NUL `-0` -> `:`) if grouping didn't shrink it. + let plain = raw_output.replace('\0', ":"); + let output = if rtk_output.len() < plain.len() { + rtk_output + } else { + plain + }; + + print!("{}", output); timer.track( &format!("grep -rn '{}' {}", pattern_display, path_display), "rtk grep", &raw_output, - &rtk_output, + &output, ); Ok(exit_code) } -/// Parses a single rg/grep match line of the form `file\0line_number:content`. +/// Parses a single rg/grep match or context line of the form +/// `file\0line_number[:-]content`. /// /// Requires the underlying command to be invoked with `-0` (rg) or `--null` -/// (grep) so the filename is NUL-separated from `line:content`. NUL cannot -/// appear in -/// file paths, so the parser is unambiguous regardless of: +/// (grep) so the filename is NUL-separated from `line[:-]content`. NUL cannot +/// appear in file paths, so the parser is unambiguous regardless of: /// - content with `:` or `::` (e.g. `ClassRegistry::init(...)`, issue #1436); /// - paths with embedded `:` (Windows drive letters, weird filenames like /// `badly_named:52:file.txt`). /// -/// Returns `None` for lines that do not match the expected shape (e.g. rg -/// `-A`/`-B` context lines that use `-` as separator). -fn parse_match_line(line: &str) -> Option<(String, usize, &str)> { +/// Returns `None` for lines that do not match the expected shape. +/// The `bool` in the tuple is `true` for match lines (`:` separator) and +/// `false` for context lines (`-` separator, emitted by -A/-B/-C). +fn parse_match_line(line: &str) -> Option<(String, usize, bool, &str)> { lazy_static::lazy_static! { - static ref MATCH_LINE_RE: Regex = Regex::new(r"^([^\x00]+)\x00(\d+):(.*)$").unwrap(); + static ref MATCH_LINE_RE: Regex = Regex::new(r"^([^\x00]+)\x00(\d+)([:-])(.*)$").unwrap(); } MATCH_LINE_RE.captures(line).and_then(|caps| { - let (_, [file, line_num, content]) = caps.extract(); - let line_num: usize = line_num.parse().ok()?; - Some((file.to_string(), line_num, content)) + let file = caps.get(1)?.as_str().to_string(); + let line_num: usize = caps.get(2)?.as_str().parse().ok()?; + let sep = caps.get(3)?.as_str(); + let content = caps.get(4)?.as_str(); + let is_match = sep == ":"; + Some((file, line_num, is_match, content)) }) } @@ -1119,6 +1280,39 @@ mod tests { assert!(!has_format_flag(&["-i", "-w", "-A", "3"])); } + #[test] + fn test_strip_rg_only_drops_glob_and_value() { + assert_eq!(strip_rg_only(&["--glob", "*.rs", "-i"]), vec!["-i"]); + assert_eq!(strip_rg_only(&["-g", "*.rs", "-i"]), vec!["-i"]); + } + + #[test] + fn test_strip_rg_only_drops_inline_glob() { + assert_eq!(strip_rg_only(&["--glob=*.rs", "-i"]), vec!["-i"]); + } + + #[test] + fn test_strip_rg_only_drops_bool_flags() { + assert_eq!( + strip_rg_only(&["--hidden", "--pcre2", "--json", "-n"]), + vec!["-n"] + ); + } + + #[test] + fn test_strip_rg_only_drops_type_and_value() { + assert_eq!(strip_rg_only(&["--type", "rust", "-w"]), vec!["-w"]); + assert_eq!(strip_rg_only(&["-T", "rust", "-w"]), vec!["-w"]); + } + + #[test] + fn test_strip_rg_only_keeps_grep_compatible() { + assert_eq!( + strip_rg_only(&["-i", "-w", "-A", "3", "-v"]), + vec!["-i", "-w", "-A", "3", "-v"] + ); + } + // Verify line numbers are always enabled in rg invocation (grep_cmd.rs:24). // The -n/--line-numbers clap flag in main.rs is a no-op accepted for compat. #[test] @@ -1138,14 +1332,15 @@ mod tests { } // --- issue #1436: parse_match_line robustness --- - // Input shape is `file\0line:content` (rg --null / grep -Z). + // Input shape is `file\0line[:-]content` (rg --null / grep -Z). #[test] fn test_parse_match_line_simple() { let line = "file.php\x0010:use Foo\\Bar;"; - let (file, line_num, content) = parse_match_line(line).unwrap(); + let (file, line_num, is_match, content) = parse_match_line(line).unwrap(); assert_eq!(file, "file.php"); assert_eq!(line_num, 10); + assert!(is_match); assert_eq!(content, "use Foo\\Bar;"); } @@ -1155,9 +1350,10 @@ mod tests { #[test] fn test_parse_match_line_content_with_double_colon() { let line = "externalImportShell.class.php\x0081: $this->queueProcessModel = ClassRegistry::init('Collections.QueueProcess');"; - let (file, line_num, content) = parse_match_line(line).unwrap(); + let (file, line_num, is_match, content) = parse_match_line(line).unwrap(); assert_eq!(file, "externalImportShell.class.php"); assert_eq!(line_num, 81); + assert!(is_match); assert_eq!( content, " $this->queueProcessModel = ClassRegistry::init('Collections.QueueProcess');" @@ -1169,9 +1365,10 @@ mod tests { #[test] fn test_parse_match_line_windows_path() { let line = "C:\\src\\file.rs\x0042:fn main() {}"; - let (file, line_num, content) = parse_match_line(line).unwrap(); + let (file, line_num, is_match, content) = parse_match_line(line).unwrap(); assert_eq!(file, r"C:\src\file.rs"); assert_eq!(line_num, 42); + assert!(is_match); assert_eq!(content, "fn main() {}"); } @@ -1180,9 +1377,10 @@ mod tests { #[test] fn test_parse_match_line_filename_with_colons() { let line = "badly_named:52:file.txt\x001:xxx"; - let (file, line_num, content) = parse_match_line(line).unwrap(); + let (file, line_num, is_match, content) = parse_match_line(line).unwrap(); assert_eq!(file, "badly_named:52:file.txt"); assert_eq!(line_num, 1); + assert!(is_match); assert_eq!(content, "xxx"); } @@ -1191,9 +1389,10 @@ mod tests { #[test] fn test_parse_match_line_content_with_digit_colons() { let line = "log.txt\x007:debug: counter is :42: now"; - let (file, line_num, content) = parse_match_line(line).unwrap(); + let (file, line_num, is_match, content) = parse_match_line(line).unwrap(); assert_eq!(file, "log.txt"); assert_eq!(line_num, 7); + assert!(is_match); assert_eq!(content, "debug: counter is :42: now"); } @@ -1212,12 +1411,24 @@ mod tests { #[test] fn test_parse_match_line_empty_content() { let line = "file.rs\x007:"; - let (file, line_num, content) = parse_match_line(line).unwrap(); + let (file, line_num, is_match, content) = parse_match_line(line).unwrap(); assert_eq!(file, "file.rs"); assert_eq!(line_num, 7); + assert!(is_match); assert_eq!(content, ""); } + // Context line: separator is `-` → is_match==false + #[test] + fn test_parse_match_line_context_line() { + let line = "file.txt\x004-after1"; + let (file, line_num, is_match, content) = parse_match_line(line).unwrap(); + assert_eq!(file, "file.txt"); + assert_eq!(line_num, 4); + assert!(!is_match, "dash separator must yield is_match==false"); + assert_eq!(content, "after1"); + } + #[test] fn test_rg_no_ignore_vcs_flag_accepted() { // Verify rg accepts --no-ignore-vcs (used to match grep -r behavior for .gitignore) @@ -1237,4 +1448,49 @@ mod tests { } // If rg is not installed, skip gracefully (test still passes) } + + // --- unparsed_signal --- + + #[test] + fn test_unparsed_signal_parseable_lines_yield_zero() { + // NUL-separated match lines all parse → signal == 0 + let stdout = "file.txt\x001:hello\nfile.txt\x002:world\n"; + assert_eq!(unparsed_signal(stdout), 0); + } + + #[test] + fn test_unparsed_signal_context_separator_not_counted() { + // The `--` context separator emitted by rg/grep between match groups + // must not be counted as an unparsed line. + let stdout = "file.txt\x001:hello\n--\nfile.txt\x003:world\n"; + assert_eq!(unparsed_signal(stdout), 0); + } + + #[test] + fn test_unparsed_signal_empty_line_not_counted() { + let stdout = "file.txt\x001:hello\n\nfile.txt\x002:world\n"; + assert_eq!(unparsed_signal(stdout), 0); + } + + #[test] + fn test_unparsed_signal_bare_colon_line_counted() { + // A line like "file.rs:1:content" (no NUL) is what --heading or + // --no-filename output looks like — it must be counted. + let stdout = "file.rs:1:content\n"; + assert_eq!(unparsed_signal(stdout), 1); + } + + #[test] + fn test_unparsed_signal_binary_notice_counted() { + // rg emits "Binary file foo matches" for binary files; no NUL → counted. + let stdout = "Binary file foo matches\n"; + assert_eq!(unparsed_signal(stdout), 1); + } + + #[test] + fn test_unparsed_signal_context_lines_parse_ok() { + // Context lines (dash separator) parse via the updated regex → not counted. + let stdout = "file.txt\x003-context_before\nfile.txt\x004:match\nfile.txt\x005-context_after\n"; + assert_eq!(unparsed_signal(stdout), 0); + } } diff --git a/tests/grep_compress_test.rs b/tests/grep_compress_test.rs new file mode 100644 index 0000000000..a0366bd63e --- /dev/null +++ b/tests/grep_compress_test.rs @@ -0,0 +1,319 @@ +#![cfg(unix)] +//! Integration tests for the GROUP path with context flags (-A/-B/-C) and the +//! safety-net passthrough for flags that break the NUL-based reparse. + +use std::process::Command; + +fn rtk() -> Command { + Command::new(env!("CARGO_BIN_EXE_rtk")) +} + +fn rg_available() -> bool { + Command::new("rg") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +fn write_temp(content: &str) -> (tempfile::TempDir, std::path::PathBuf) { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("test.txt"); + std::fs::write(&path, content).expect("write"); + (dir, path) +} + +// --- context compression (the gain win) --- + +#[test] +fn after_context_yields_grouped_header_with_correct_match_count() { + if !rg_available() { + return; + } + let long = "x".repeat(120); + let content = + format!("before {long}\nMATCH {long}\nafter1 {long}\nafter2 {long}\nend {long}\n"); + let (_dir, path) = write_temp(&content); + let out = rtk() + .args(["grep", "-A2", "MATCH", path.to_str().unwrap()]) + .output() + .expect("rtk grep"); + let stdout = String::from_utf8_lossy(&out.stdout); + + assert!( + stdout.contains("1 matches"), + "header must count only the match line, not context lines:\n{stdout}" + ); + assert!( + stdout.contains("after1"), + "after-context line 1 missing:\n{stdout}" + ); + assert!( + stdout.contains("after2"), + "after-context line 2 missing:\n{stdout}" + ); +} + +#[test] +fn after_context_uses_dash_separator_for_context_lines() { + if !rg_available() { + return; + } + let (_dir, path) = write_temp("MATCH\nafter1\n"); + let out = rtk() + .args(["grep", "-A1", "MATCH", path.to_str().unwrap()]) + .output() + .expect("rtk grep"); + let stdout = String::from_utf8_lossy(&out.stdout); + + let has_context_dash = stdout.lines().any(|l| l.contains("-after1")); + assert!( + has_context_dash, + "context lines must use dash separator:\n{stdout}" + ); +} + +#[test] +fn plain_grep_still_shows_grouped_header() { + if !rg_available() { + return; + } + let long = "x".repeat(120); + let content = format!("foo {long}\nbaz\nfoo {long}\n"); + let (_dir, path) = write_temp(&content); + let out = rtk() + .args(["grep", "foo", path.to_str().unwrap()]) + .output() + .expect("rtk grep"); + let stdout = String::from_utf8_lossy(&out.stdout); + + assert!( + stdout.contains("matches in"), + "grouped header must show when grouping compresses:\n{stdout}" + ); +} + +#[test] +fn true_no_match_exits_1() { + if !rg_available() { + return; + } + let (_dir, path) = write_temp("hello world\n"); + let out = rtk() + .args(["grep", "zzzz_no_match_xyz", path.to_str().unwrap()]) + .output() + .expect("rtk grep"); + + assert_eq!( + out.status.code(), + Some(1), + "true no-match must exit 1, not 0" + ); +} + +// --- safety net: flags that break the NUL-based reparse --- + +#[test] +fn no_line_number_flag_produces_output_not_zero_matches() { + if !rg_available() { + return; + } + let (_dir, path) = write_temp("hello world\n"); + let out = rtk() + .args(["grep", "-N", "hello", path.to_str().unwrap()]) + .output() + .expect("rtk grep"); + let stdout = String::from_utf8_lossy(&out.stdout); + + assert!( + !stdout.contains("0 matches"), + "-N output must not be reported as '0 matches':\n{stdout}" + ); + assert!( + stdout.contains("hello"), + "-N output must contain the matching line:\n{stdout}" + ); +} + +#[test] +fn no_filename_flag_produces_output_not_zero_matches() { + if !rg_available() { + return; + } + let (_dir, path) = write_temp("hello world\n"); + let out = rtk() + .args(["grep", "-I", "hello", path.to_str().unwrap()]) + .output() + .expect("rtk grep"); + let stdout = String::from_utf8_lossy(&out.stdout); + + assert!( + !stdout.contains("0 matches"), + "-I output must not be reported as '0 matches':\n{stdout}" + ); + assert!( + stdout.contains("hello"), + "-I output must contain the matching line:\n{stdout}" + ); +} + +#[test] +fn heading_flag_produces_output_not_zero_matches() { + if !rg_available() { + return; + } + let (_dir, path) = write_temp("hello world\n"); + let out = rtk() + .args(["grep", "--heading", "hello", path.to_str().unwrap()]) + .output() + .expect("rtk grep"); + let stdout = String::from_utf8_lossy(&out.stdout); + + assert!( + !stdout.contains("0 matches"), + "--heading output must not be reported as '0 matches':\n{stdout}" + ); + assert!( + stdout.contains("hello"), + "--heading output must contain the matching line:\n{stdout}" + ); +} + +#[test] +fn pretty_flag_produces_output_not_zero_matches() { + if !rg_available() { + return; + } + let (_dir, path) = write_temp("hello world\n"); + let out = rtk() + .args(["grep", "-p", "hello", path.to_str().unwrap()]) + .output() + .expect("rtk grep"); + let stdout = String::from_utf8_lossy(&out.stdout); + + assert!( + !stdout.contains("0 matches"), + "-p output must not be reported as '0 matches':\n{stdout}" + ); + assert!( + stdout.contains("hello"), + "-p output must contain the matching line:\n{stdout}" + ); +} + +// --- shape flags: passthrough, no NUL leak --- + +#[test] +fn column_flag_output_has_no_nul() { + if !rg_available() { + return; + } + let (_dir, path) = write_temp("hello world\n"); + let out = rtk() + .args(["grep", "--column", "hello", path.to_str().unwrap()]) + .output() + .expect("rtk grep"); + let stdout = String::from_utf8_lossy(&out.stdout); + + assert!( + !stdout.contains('\u{0}'), + "--column output must not contain NUL:\n{stdout:?}" + ); + assert!( + stdout.contains("hello"), + "--column output must contain the match:\n{stdout}" + ); +} + +// --- token savings (the compression gain) --- + +fn count_tokens(s: &str) -> usize { + s.split_whitespace().count() +} + +#[test] +fn bulky_grep_yields_token_savings() { + if !rg_available() { + return; + } + let filler: String = (0..50).map(|i| format!("word{i} ")).collect(); + let mut content = String::new(); + for i in 0..20 { + content.push_str(&format!("MATCH line {i} {filler}\n")); + } + let (_dir, path) = write_temp(&content); + + let raw = Command::new("rg") + .args(["-nH", "MATCH", path.to_str().unwrap()]) + .output() + .expect("rg"); + let raw_tokens = count_tokens(&String::from_utf8_lossy(&raw.stdout)); + + let out = rtk() + .args(["grep", "MATCH", path.to_str().unwrap()]) + .output() + .expect("rtk grep"); + let rtk_tokens = count_tokens(&String::from_utf8_lossy(&out.stdout)); + + let savings = 100.0 - (rtk_tokens as f64 / raw_tokens as f64 * 100.0); + assert!( + savings >= 50.0, + "expected >=50% token savings, got {savings:.1}% (raw={raw_tokens}, rtk={rtk_tokens})" + ); +} + +// --- grep-only syntax falls back to system grep (#2543) --- + +#[test] +fn grep_only_flags_fall_back_to_system_grep() { + if !rg_available() { + return; + } + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write(dir.path().join("a.java"), "DRAFT in java\n").expect("write"); + std::fs::write(dir.path().join("b.txt"), "DRAFT in text\n").expect("write"); + let path = dir.path().to_str().unwrap(); + + let out = rtk() + .args(["grep", "-rn", "DRAFT", "--include=*.java", path]) + .output() + .expect("rtk grep"); + let stdout = String::from_utf8_lossy(&out.stdout); + + assert!( + stdout.contains("a.java"), + "--include=*.java should match the java file:\n{stdout}" + ); + assert!( + !stdout.contains("b.txt"), + "--include=*.java should exclude the txt file:\n{stdout}" + ); + assert!( + !stdout.contains("grep failed") && !stdout.contains("unrecognized"), + "rg-incompatible flag must fall back to grep, not error:\n{stdout}" + ); +} + +// --- never-worse guard: small greps must not cost more than plain grep --- + +#[test] +fn small_grep_not_worse_than_plain() { + if !rg_available() { + return; + } + let (_dir, path) = write_temp("foo\n"); + let out = rtk() + .args(["grep", "foo", path.to_str().unwrap()]) + .output() + .expect("rtk grep"); + let stdout = String::from_utf8_lossy(&out.stdout); + + assert!( + stdout.contains(":1:foo"), + "should show the match:\n{stdout}" + ); + assert!( + !stdout.contains("matches in"), + "header that costs more than raw must be dropped:\n{stdout}" + ); +} diff --git a/tests/grep_context_test.rs b/tests/grep_context_test.rs new file mode 100644 index 0000000000..461a6e3727 --- /dev/null +++ b/tests/grep_context_test.rs @@ -0,0 +1,111 @@ +#![cfg(unix)] +//! Regressions from the #2333 grep overhaul: -A/-B/-C must keep context lines, +//! -c/-o must not leak ripgrep's NUL separator. + +use std::process::Command; + +fn rtk() -> Command { + Command::new(env!("CARGO_BIN_EXE_rtk")) +} + +fn rg_available() -> bool { + Command::new("rg") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +#[test] +fn after_context_lines_are_shown() { + if !rg_available() { + return; + } + let dir = tempfile::tempdir().unwrap(); + let f = dir.path().join("ctx.txt"); + std::fs::write(&f, "before1\nbefore2\nMATCHME\nafter1\nafter2\nafter3\n").unwrap(); + let out = rtk() + .args(["grep", "-A2", "MATCHME", f.to_str().unwrap()]) + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("after1"), + "missing after-context line 1:\n{stdout}" + ); + assert!( + stdout.contains("after2"), + "missing after-context line 2:\n{stdout}" + ); + assert!(!stdout.contains("after3"), "leaked beyond -A2:\n{stdout}"); +} + +#[test] +fn before_context_lines_are_shown() { + if !rg_available() { + return; + } + let dir = tempfile::tempdir().unwrap(); + let f = dir.path().join("ctx.txt"); + std::fs::write(&f, "before1\nbefore2\nMATCHME\nafter1\n").unwrap(); + let out = rtk() + .args(["grep", "-B1", "MATCHME", f.to_str().unwrap()]) + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("before2"), + "missing before-context line:\n{stdout}" + ); + assert!(!stdout.contains("before1"), "leaked beyond -B1:\n{stdout}"); +} + +#[test] +fn count_output_has_no_nul_separator() { + if !rg_available() { + return; + } + let dir = tempfile::tempdir().unwrap(); + let f = dir.path().join("nul.txt"); + std::fs::write(&f, "TODO one\nnope\nTODO two\n").unwrap(); + let out = rtk() + .args(["grep", "-c", "TODO", f.to_str().unwrap()]) + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + !stdout.contains('\u{0}'), + "NUL leaked into -c output: {stdout:?}" + ); + assert!( + stdout.contains('2'), + "count missing from -c output: {stdout:?}" + ); +} + +#[test] +fn only_matching_output_has_no_nul_separator() { + if !rg_available() { + return; + } + let dir = tempfile::tempdir().unwrap(); + let f = dir.path().join("nul.txt"); + std::fs::write(&f, "TODO one\nnope\nTODO two\n").unwrap(); + let out = rtk() + .args(["grep", "-o", "TODO", f.to_str().unwrap()]) + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + !stdout.contains('\u{0}'), + "NUL leaked into -o output: {stdout:?}" + ); + assert!( + stdout.contains("TODO"), + "match missing from -o output: {stdout:?}" + ); + assert!( + !stdout.contains("1:TODO"), + "line-number prefix leaked into -o output: {stdout:?}" + ); +} From 5cfe4d5f87cecc7996cdbd837b7bf3dab2518a4e Mon Sep 17 00:00:00 2001 From: Wladi Mitzel Date: Tue, 23 Jun 2026 14:51:39 +0200 Subject: [PATCH 29/50] fix(pulumi): keep Owner and version in pulumi-stack filter Per review: avoid signal loss that pushes agents to retry with `rtk proxy`. pulumi-stack now keeps the full stack identity block (Owner:, Pulumi version used:) alongside the header and Last updated:. Only interactive prompt and help-footer noise is stripped. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/filters/pulumi-stack.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/filters/pulumi-stack.toml b/src/filters/pulumi-stack.toml index 5518a07a25..27850fed33 100644 --- a/src/filters/pulumi-stack.toml +++ b/src/filters/pulumi-stack.toml @@ -8,8 +8,6 @@ match_output = [ strip_lines_matching = [ "^\\s*$", "^Please choose a stack", - "^\\s*Owner:", - "^\\s*Pulumi version used:", "^More information at:", "^Use `pulumi ", "^Current stack outputs \\(0\\):", @@ -23,7 +21,7 @@ input = "No stacks found in the current workspace.\n" expected = "pulumi stack: empty" [[tests.pulumi-stack]] -name = "keeps stack identity, strips version noise" +name = "keeps stack identity, strips prompt noise" input = """ Please choose a stack, or create a new one: Current stack is dev: @@ -40,7 +38,9 @@ Current stack resources (3): """ expected = """Current stack is dev: Managed by my-user + Owner: my-org Last updated: 2 hours ago + Pulumi version used: v3.120.0 Current stack resources (3): TYPE NAME pulumi:pulumi:Stack my-proj-dev From 3a59d2323524594d3182d301838cdb5953c00b36 Mon Sep 17 00:00:00 2001 From: Adrien Eppling Date: Tue, 23 Jun 2026 16:09:29 +0200 Subject: [PATCH 30/50] test(grep): cover bundled -rln files-with-matches cluster (#2543) Review feedback on #2550: the flag-shape tests all used standalone flags, leaving the exact #2543 repro -- a bundled -rln/-ln files-with-matches cluster -- unexercised. Assert rtk grep -rln lists matching files instead of a false '0 matches'. --- tests/grep_compress_test.rs | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tests/grep_compress_test.rs b/tests/grep_compress_test.rs index a0366bd63e..bd36df3e7d 100644 --- a/tests/grep_compress_test.rs +++ b/tests/grep_compress_test.rs @@ -317,3 +317,36 @@ fn small_grep_not_worse_than_plain() { "header that costs more than raw must be dropped:\n{stdout}" ); } + +// --- #2543: bundled files-with-matches cluster (-rln / -ln) lists files, not "0 matches" --- + +#[test] +fn bundled_files_with_matches_cluster_lists_files() { + if !rg_available() { + return; + } + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write(dir.path().join("a.txt"), "TODO alpha\n").expect("write"); + std::fs::write(dir.path().join("b.txt"), "TODO beta\n").expect("write"); + std::fs::write(dir.path().join("c.txt"), "nothing here\n").expect("write"); + let path = dir.path().to_str().unwrap(); + + let out = rtk() + .args(["grep", "-rln", "TODO", path]) + .output() + .expect("rtk grep"); + let stdout = String::from_utf8_lossy(&out.stdout); + + assert!( + !stdout.contains("0 matches"), + "-rln must list files, not report a false '0 matches' (#2543):\n{stdout}" + ); + assert!( + stdout.contains("a.txt") && stdout.contains("b.txt"), + "-rln must list every matching file:\n{stdout}" + ); + assert!( + !stdout.contains("c.txt"), + "-rln must not list non-matching files:\n{stdout}" + ); +} From 861a46dee57f50216862ac83b0ee57974f383203 Mon Sep 17 00:00:00 2001 From: Adrien Eppling Date: Tue, 23 Jun 2026 21:53:35 +0200 Subject: [PATCH 31/50] fix(core): never-worse output guard so RTK never exceeds the raw command RTK could emit more tokens than the underlying command on small inputs: filters that add headers, summaries, re-indentation, or a tee hint, plus synthetic no-result messages ("0 matches", "No stashes", "[docker] 0 containers") printed where the raw command emitted nothing. Both break the Transparency principle and inflate tokens instead of saving them. - core::guard::never_worse(raw, filtered) returns raw when the filtered form has more tokens (reuses tracking::estimate_tokens), so RTK output is never larger than the real command. - runner::emit_guarded(filtered, hint, raw) composes body + tee hint, guards the whole, prints, and returns what was shown so printed == tracked. - run_captured_filter guards the run_filtered* family centrally; per-site guards cover the remaining single-string filters. - On empty raw, emit empty and preserve the exit code instead of a synthetic no-result message (the messages were cosmetic with no dependents; #2461 reports the grep one as actively harmful). - git stash show now propagates its exit code instead of masking a real failure as Ok(0). Resolves #2551. --- CONTRIBUTING.md | 2 + src/cmds/cloud/aws_cmd.rs | 19 ++-- src/cmds/cloud/container.rs | 29 +++--- src/cmds/cloud/curl_cmd.rs | 8 +- src/cmds/cloud/wget_cmd.rs | 21 +++-- src/cmds/dotnet/dotnet_cmd.rs | 11 ++- src/cmds/git/diff_cmd.rs | 11 ++- src/cmds/git/git.rs | 84 ++++++++++-------- src/cmds/git/gt_cmd.rs | 9 +- src/cmds/go/go_cmd.rs | 6 +- src/cmds/js/lint_cmd.rs | 9 +- src/cmds/js/playwright_cmd.rs | 9 +- src/cmds/js/pnpm_cmd.rs | 23 +++-- src/cmds/js/prisma_cmd.rs | 16 ++-- src/cmds/js/vitest_cmd.rs | 8 +- src/cmds/python/pip_cmd.rs | 5 +- src/cmds/system/deps.rs | 6 +- src/cmds/system/env_cmd.rs | 38 ++++---- src/cmds/system/find_cmd.rs | 40 ++++----- src/cmds/system/format_cmd.rs | 6 +- src/cmds/system/grep_cmd.rs | 9 +- src/cmds/system/json_cmd.rs | 11 ++- src/cmds/system/log_cmd.rs | 11 ++- src/cmds/system/read.rs | 11 ++- src/cmds/system/summary.rs | 6 +- src/core/guard.rs | 56 ++++++++++++ src/core/mod.rs | 1 + src/core/runner.rs | 51 +++++++---- src/main.rs | 8 +- tests/guard_integration_test.rs | 151 ++++++++++++++++++++++++++++++++ 30 files changed, 470 insertions(+), 205 deletions(-) create mode 100644 src/core/guard.rs create mode 100644 tests/guard_integration_test.rs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dec1a0527e..b10b9e710b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -46,6 +46,8 @@ The LLM doesn't know RTK is involved for which commands, hooks rewrite commands Don't invent new output formats. Don't add RTK-specific headers or markers in the default output. The filtered output should be indistinguishable from "a shorter version of the real command." +Enforce it with `guard::never_worse(raw, filtered)` — print and track the value it returns (use `runner::emit_guarded(filtered, hint, raw)` when appending a tee hint). It guarantees RTK never emits more tokens than the raw command, down to emitting nothing when the command produced nothing. + ### Never Block If a filter fails, fall back to raw output. RTK should never prevent a command from executing or producing output. Better to pass through unfiltered than to error out. Same for hooks: exit 0 on all error paths so the agent's command runs unmodified. diff --git a/src/cmds/cloud/aws_cmd.rs b/src/cmds/cloud/aws_cmd.rs index 833d2fe7b9..0f8287af00 100644 --- a/src/cmds/cloud/aws_cmd.rs +++ b/src/cmds/cloud/aws_cmd.rs @@ -3,6 +3,7 @@ //! Replaces verbose `--output table`/`text` with JSON, then compresses. //! Specialized filters for high-frequency commands (STS, S3, EC2, ECS, RDS, CloudFormation). +use crate::core::guard::never_worse; use crate::core::tee::force_tee_hint; use crate::core::tracking; use crate::core::truncate::{CAP_INVENTORY, CAP_LIST}; @@ -258,6 +259,7 @@ fn run_generic(subcommand: &str, args: &[String], verbose: u8, full_sub: &str) - let filtered = match json_cmd::filter_json_compact(&raw, JSON_COMPRESS_DEPTH) { Ok(compact) => { + let compact = never_worse(&raw, &compact).to_string(); println!("{}", compact); compact } @@ -361,19 +363,14 @@ fn run_aws_filtered( FilterResult::new(stdout.clone()) }); - if result.truncated { - if let Some(hint) = crate::core::tee::force_tee_hint(&raw, &slug) { - println!("{}\n{}", result.text, hint); - } else { - println!("{}", result.text); - } - } else if let Some(hint) = crate::core::tee::tee_and_hint(&raw, &slug, 0) { - println!("{}\n{}", result.text, hint); + let hint = if result.truncated { + crate::core::tee::force_tee_hint(&raw, &slug) } else { - println!("{}", result.text); - } + crate::core::tee::tee_and_hint(&raw, &slug, 0) + }; + let shown = crate::core::runner::emit_guarded(&result.text, hint.as_deref(), &raw); - timer.track(&cmd_label, &rtk_label, &raw, &result.text); + timer.track(&cmd_label, &rtk_label, &raw, &shown); Ok(0) } diff --git a/src/cmds/cloud/container.rs b/src/cmds/cloud/container.rs index 7b26683108..10f2ce1c8d 100644 --- a/src/cmds/cloud/container.rs +++ b/src/cmds/cloud/container.rs @@ -1,5 +1,6 @@ //! Filters Docker and kubectl output into compact summaries. +use crate::core::guard::never_worse; use crate::core::runner::{self, RunOptions}; use crate::core::stream::exec_capture; use crate::core::tracking; @@ -78,9 +79,7 @@ fn docker_ps(_verbose: u8) -> Result { let mut rtk = String::new(); if stdout.trim().is_empty() { - rtk.push_str("[docker] 0 containers"); - println!("{}", rtk); - timer.track("docker ps", "rtk docker ps", &raw, &rtk); + timer.track("docker ps", "rtk docker ps", &raw, ""); return Ok(0); } @@ -103,8 +102,9 @@ fn docker_ps(_verbose: u8) -> Result { } } - print!("{}", rtk); - timer.track("docker ps", "rtk docker ps", &raw, &rtk); + let shown = never_worse(&raw, &rtk); + print!("{}", shown); + timer.track("docker ps", "rtk docker ps", &raw, shown); Ok(0) } @@ -180,8 +180,9 @@ fn docker_ps_all(_verbose: u8) -> Result { } } - print!("{}", rtk); - timer.track("docker ps -a", "rtk docker ps -a", &raw, &rtk); + let shown = never_worse(&raw, &rtk); + print!("{}", shown); + timer.track("docker ps -a", "rtk docker ps -a", &raw, shown); Ok(0) } @@ -239,9 +240,7 @@ fn docker_images(_verbose: u8) -> Result { let mut rtk = String::new(); if lines.is_empty() { - rtk.push_str("[docker] 0 images"); - println!("{}", rtk); - timer.track("docker images", "rtk docker images", &raw, &rtk); + timer.track("docker images", "rtk docker images", &raw, ""); return Ok(0); } @@ -299,8 +298,9 @@ fn docker_images(_verbose: u8) -> Result { } } - print!("{}", rtk); - timer.track("docker images", "rtk docker images", &raw, &rtk); + let shown = never_worse(&raw, &rtk); + print!("{}", shown); + timer.track("docker images", "rtk docker images", &raw, shown); Ok(0) } @@ -692,10 +692,11 @@ pub fn run_compose_ps(all: bool, verbose: u8) -> Result { } let rtk = format_compose_ps(&structured); - println!("{}", rtk); + let shown = never_worse(&raw, &rtk); + println!("{}", shown); let label = if all { "docker compose ps -a" } else { "docker compose ps" }; let rtk_label = if all { "rtk docker compose ps -a" } else { "rtk docker compose ps" }; - timer.track(label, rtk_label, &raw, &rtk); + timer.track(label, rtk_label, &raw, shown); Ok(0) } diff --git a/src/cmds/cloud/curl_cmd.rs b/src/cmds/cloud/curl_cmd.rs index 7073c2e421..85f242f483 100644 --- a/src/cmds/cloud/curl_cmd.rs +++ b/src/cmds/cloud/curl_cmd.rs @@ -76,16 +76,14 @@ pub fn run(args: &[String], verbose: u8) -> Result { let is_tty = std::io::stdout().is_terminal(); let filtered = filter_curl_output(&raw, is_tty); - println!("{}", filtered.content); - if let Some(hint) = &filtered.tee_hint { - println!("{}", hint); - } + let shown = + crate::core::runner::emit_guarded(&filtered.content, filtered.tee_hint.as_deref(), &raw); timer.track( &format!("curl {}", args.join(" ")), &format!("rtk curl {}", args.join(" ")), &raw, - &filtered.content, + &shown, ); Ok(exit_code) diff --git a/src/cmds/cloud/wget_cmd.rs b/src/cmds/cloud/wget_cmd.rs index 09e52f24be..1f46262525 100644 --- a/src/cmds/cloud/wget_cmd.rs +++ b/src/cmds/cloud/wget_cmd.rs @@ -1,3 +1,4 @@ +use crate::core::guard::never_worse; use crate::core::stream::exec_capture; use crate::core::tracking; use crate::core::utils::resolved_command; @@ -35,13 +36,15 @@ pub fn run(url: &str, args: &[String], verbose: u8) -> Result { filename, format_size(size) ); - println!("{}", msg); - timer.track(&format!("wget {}", url), "rtk wget", &raw_output, &msg); + let shown = never_worse(&raw_output, &msg); + println!("{}", shown); + timer.track(&format!("wget {}", url), "rtk wget", &raw_output, shown); } else { let error = parse_error(&result.stderr, &result.stdout); let msg = format!("{} FAILED: {}", compact_url(url), error); - println!("{}", msg); - timer.track(&format!("wget {}", url), "rtk wget", &raw_output, &msg); + let shown = never_worse(&raw_output, &msg); + println!("{}", shown); + timer.track(&format!("wget {}", url), "rtk wget", &raw_output, shown); return Ok(result.exit_code); } @@ -89,18 +92,20 @@ pub fn run_stdout(url: &str, args: &[String], verbose: u8) -> Result { rtk_output.push_str(&format!("{}\n", line)); } } - print!("{}", rtk_output); + let shown = never_worse(&result.stdout, &rtk_output); + print!("{}", shown); timer.track( &format!("wget -O - {}", url), "rtk wget -o", &result.stdout, - &rtk_output, + shown, ); } else { let error = parse_error(&result.stderr, ""); let msg = format!("{} FAILED: {}", compact_url(url), error); - println!("{}", msg); - timer.track(&format!("wget -O - {}", url), "rtk wget -o", &result.stderr, &msg); + let shown = never_worse(&result.stderr, &msg); + println!("{}", shown); + timer.track(&format!("wget -O - {}", url), "rtk wget -o", &result.stderr, shown); return Ok(result.exit_code); } diff --git a/src/cmds/dotnet/dotnet_cmd.rs b/src/cmds/dotnet/dotnet_cmd.rs index 232f957af6..6ba78eea17 100644 --- a/src/cmds/dotnet/dotnet_cmd.rs +++ b/src/cmds/dotnet/dotnet_cmd.rs @@ -1,6 +1,7 @@ //! Filters dotnet CLI output — build, test, and format results. use crate::binlog; +use crate::core::guard::never_worse; use crate::core::stream::exec_capture; use crate::core::tracking; use crate::core::truncate::{CAP_ERRORS, CAP_LIST, CAP_WARNINGS}; @@ -54,13 +55,14 @@ pub fn run_format(args: &[String], verbose: u8) -> Result { let check_mode = !has_write_mode_override(args); let filtered = format_report_summary_or_raw(report_path.as_deref(), check_mode, &raw, command_started_at); - println!("{}", filtered); + let shown = never_worse(&raw, &filtered); + println!("{}", shown); timer.track( &format!("dotnet format {}", args.join(" ")), &format!("rtk dotnet format {}", args.join(" ")), &raw, - &filtered, + shown, ); if cleanup_report_path { @@ -226,13 +228,14 @@ fn run_dotnet_with_binlog(subcommand: &str, args: &[String], verbose: u8) -> Res &filtered, ); - println!("{}", output_to_print); + let shown = never_worse(&raw, &output_to_print); + println!("{}", shown); timer.track( &format!("dotnet {} {}", subcommand, args.join(" ")), &format!("rtk dotnet {} {}", subcommand, args.join(" ")), &raw, - &output_to_print, + shown, ); cleanup_temp_file(&binlog_path); diff --git a/src/cmds/git/diff_cmd.rs b/src/cmds/git/diff_cmd.rs index 4f7ae1609e..f40c857f41 100644 --- a/src/cmds/git/diff_cmd.rs +++ b/src/cmds/git/diff_cmd.rs @@ -1,5 +1,6 @@ //! Compares two files and shows only the changed lines. +use crate::core::guard::never_worse; use crate::core::tracking; use anyhow::Result; use std::fs; @@ -20,12 +21,13 @@ pub fn run(file1: &Path, file2: &Path, verbose: u8) -> Result { let (rtk, exit_code) = render_file_diff(file1, file2, &content1, &content2); - print!("{}", rtk); + let shown = never_worse(&raw, &rtk); + print!("{}", shown); timer.track( &format!("diff {} {}", file1.display(), file2.display()), "rtk diff", &raw, - &rtk, + shown, ); Ok(exit_code) } @@ -61,9 +63,10 @@ pub fn run_stdin(_verbose: u8) -> Result<()> { // Parse unified diff format let condensed = condense_unified_diff(&input); - println!("{}", condensed); + let shown = never_worse(&input, &condensed); + println!("{}", shown); - timer.track("diff (stdin)", "rtk diff (stdin)", &input, &condensed); + timer.track("diff (stdin)", "rtk diff (stdin)", &input, shown); Ok(()) } diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index 29fd3b73ae..5efad3c518 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -1,6 +1,7 @@ //! Filters git output — log, status, diff, and more — keeping just the essential info. use crate::core::args_utils; +use crate::core::guard::never_worse; use crate::core::stream::{ self, exec_capture, CaptureResult, FilterMode, LineHandler, LineStreamFilter, StdinMode, }; @@ -179,9 +180,6 @@ fn run_diff( eprintln!("Git diff summary:"); } - // Print stat summary first - println!("{}", result.stdout.trim()); - // Now get actual diff but compact it let mut diff_cmd = git_cmd(global_args); diff_cmd.arg("diff"); @@ -191,20 +189,22 @@ fn run_diff( let diff_result = exec_capture(&mut diff_cmd).context("Failed to run git diff")?; - let mut final_output = result.stdout.clone(); - if !diff_result.stdout.is_empty() { - println!("\nChanges:"); + let printed = if !diff_result.stdout.is_empty() { let compacted = compact_diff(&diff_result.stdout, max_lines.unwrap_or(500)); - println!("{}", compacted); - final_output.push_str("\nChanges:\n"); - final_output.push_str(&compacted); - } + format!("{}\n\nChanges:\n{}", result.stdout.trim(), compacted) + } else { + result.stdout.trim().to_string() + }; + + let raw = format!("{}\n{}", result.stdout, diff_result.stdout); + let shown = never_worse(&raw, &printed); + println!("{}", shown); timer.track( &format!("git diff {}", args.join(" ")), &format!("rtk git diff {}", args.join(" ")), - &format!("{}\n{}", result.stdout, diff_result.stdout), - &final_output, + &raw, + shown, ); Ok(0) @@ -279,7 +279,7 @@ fn run_show( eprintln!("{}", summary_result.stderr); return Ok(summary_result.exit_code); } - println!("{}", summary_result.stdout.trim()); + let mut printed = summary_result.stdout.trim().to_string(); // Step 2: --stat summary let mut stat_cmd = git_cmd(global_args); @@ -290,7 +290,8 @@ fn run_show( let stat_result = exec_capture(&mut stat_cmd).context("Failed to run git show --stat")?; let stat_text = stat_result.stdout.trim(); if !stat_text.is_empty() { - println!("{}", stat_text); + printed.push('\n'); + printed.push_str(stat_text); } // Step 3: compacted diff @@ -302,21 +303,23 @@ fn run_show( let diff_result = exec_capture(&mut diff_cmd).context("Failed to run git show (diff)")?; let diff_text = diff_result.stdout.trim(); - let mut final_output = summary_result.stdout.clone(); if !diff_text.is_empty() { if verbose > 0 { - println!("\nChanges:"); + printed.push_str("\n\nChanges:"); } let compacted = compact_diff(diff_text, max_lines.unwrap_or(500)); - println!("{}", compacted); - final_output.push_str(&format!("\n{}", compacted)); + printed.push('\n'); + printed.push_str(&compacted); } + let shown = never_worse(&raw_output, &printed); + println!("{}", shown); + timer.track( &format!("git show {}", args.join(" ")), &format!("rtk git show {}", args.join(" ")), &raw_output, - &final_output, + shown, ); Ok(0) @@ -489,6 +492,7 @@ fn run_log( // Post-process: truncate long messages, cap lines only if RTK set the default let filtered = filter_log_output(&result.stdout, limit, user_set_limit, has_format_flag); + let filtered = never_worse(&result.stdout, &filtered).to_string(); println!("{}", filtered); timer.track( @@ -840,6 +844,7 @@ fn run_status(args: &[String], verbose: u8, global_args: &[String]) -> Result Result Result formatted, }; - println!("{}", final_output); + let shown = never_worse(&raw_output, &final_output); + println!("{}", shown); let original_cmd = if args.is_empty() { "git status".to_string() @@ -905,7 +912,7 @@ fn run_status(args: &[String], verbose: u8, global_args: &[String]) -> Result Result Result< let result = exec_capture(&mut cmd).context("Failed to run git worktree list")?; let filtered = filter_worktree_list(&result.stdout); + let filtered = never_worse(&result.stdout, &filtered).to_string(); println!("{}", filtered); timer.track( "git worktree list", diff --git a/src/cmds/git/gt_cmd.rs b/src/cmds/git/gt_cmd.rs index 1fb3ee711a..266fd63f81 100644 --- a/src/cmds/git/gt_cmd.rs +++ b/src/cmds/git/gt_cmd.rs @@ -59,11 +59,8 @@ fn run_gt_filtered( filter_fn(&clean) }; - if let Some(hint) = crate::core::tee::tee_and_hint(&raw, tee_label, cmd_output.exit_code) { - println!("{}\n{}", output, hint); - } else { - println!("{}", output); - } + let hint = crate::core::tee::tee_and_hint(&raw, tee_label, cmd_output.exit_code); + let shown = crate::core::runner::emit_guarded(&output, hint.as_deref(), &raw); if !cmd_output.stderr.trim().is_empty() { eprintln!("{}", cmd_output.stderr.trim()); @@ -75,7 +72,7 @@ fn run_gt_filtered( format!("gt {} {}", subcmd_str, args.join(" ")) }; let rtk_label = format!("rtk {}", label); - timer.track(&label, &rtk_label, &raw, &output); + timer.track(&label, &rtk_label, &raw, &shown); Ok(cmd_output.exit_code) } diff --git a/src/cmds/go/go_cmd.rs b/src/cmds/go/go_cmd.rs index fcc59df6bf..d35399bbcc 100644 --- a/src/cmds/go/go_cmd.rs +++ b/src/cmds/go/go_cmd.rs @@ -1,5 +1,6 @@ //! Filters Go command output — test results, build errors, vet warnings. +use crate::core::guard::never_worse; use crate::core::runner; use crate::core::tracking; use crate::core::truncate::CAP_ERRORS; @@ -280,7 +281,8 @@ fn run_go_tool_golangci_lint(args: &[OsString], verbose: u8) -> Result { }; let filtered = golangci_cmd::filter_golangci_json(json_output, version); - println!("{}", filtered); + let shown = never_worse(&raw, &filtered); + println!("{}", shown); if !stderr.trim().is_empty() && verbose > 0 { eprintln!("{}", stderr.trim()); @@ -290,7 +292,7 @@ fn run_go_tool_golangci_lint(args: &[OsString], verbose: u8) -> Result { "go tool golangci-lint", "rtk go tool golangci-lint", &raw, - &filtered, + shown, ); let exit_code = exit_code_from_output(&output, "go tool golangci-lint"); diff --git a/src/cmds/js/lint_cmd.rs b/src/cmds/js/lint_cmd.rs index afc3e79626..fc9c1f057c 100644 --- a/src/cmds/js/lint_cmd.rs +++ b/src/cmds/js/lint_cmd.rs @@ -199,17 +199,14 @@ pub fn run(args: &[String], verbose: u8) -> Result { _ => filter_generic_lint(&raw), }; - if let Some(hint) = crate::core::tee::tee_and_hint(&raw, "lint", result.exit_code) { - println!("{}\n{}", filtered, hint); - } else { - println!("{}", filtered); - } + let hint = crate::core::tee::tee_and_hint(&raw, "lint", result.exit_code); + let shown = crate::core::runner::emit_guarded(&filtered, hint.as_deref(), &raw); timer.track( &format!("{} {}", linter, args.join(" ")), &format!("rtk lint {} {}", linter, args.join(" ")), &raw, - &filtered, + &shown, ); if !result.success() { diff --git a/src/cmds/js/playwright_cmd.rs b/src/cmds/js/playwright_cmd.rs index bfa41ac278..aa930ec6f9 100644 --- a/src/cmds/js/playwright_cmd.rs +++ b/src/cmds/js/playwright_cmd.rs @@ -314,17 +314,14 @@ pub fn run(args: &[String], verbose: u8) -> Result { } }; - if let Some(hint) = crate::core::tee::tee_and_hint(&raw, "playwright", result.exit_code) { - println!("{}\n{}", filtered, hint); - } else { - println!("{}", filtered); - } + let hint = crate::core::tee::tee_and_hint(&raw, "playwright", result.exit_code); + let shown = crate::core::runner::emit_guarded(&filtered, hint.as_deref(), &raw); timer.track( &format!("playwright {}", args.join(" ")), &format!("rtk playwright {}", args.join(" ")), &raw, - &filtered, + &shown, ); // Preserve exit code for CI/CD diff --git a/src/cmds/js/pnpm_cmd.rs b/src/cmds/js/pnpm_cmd.rs index 6dd53f08c3..4b60775d82 100644 --- a/src/cmds/js/pnpm_cmd.rs +++ b/src/cmds/js/pnpm_cmd.rs @@ -1,5 +1,6 @@ //! Filters pnpm output — dependency trees, install logs, outdated packages. +use crate::core::guard::never_worse; use crate::core::stream::exec_capture; use crate::core::tracking; use crate::core::truncate::CAP_LIST; @@ -405,13 +406,14 @@ fn run_list(depth: usize, args: &[String], verbose: u8) -> Result { } }; - println!("{}", filtered); + let shown = never_worse(&result.stdout, &filtered); + println!("{}", shown); timer.track( &format!("pnpm list --depth={}", depth), &format!("rtk pnpm list --depth={}", depth), &result.stdout, - &filtered, + shown, ); Ok(0) @@ -455,13 +457,15 @@ fn run_outdated(args: &[String], verbose: u8) -> Result { } }; - if filtered.trim().is_empty() { - println!("All packages up-to-date"); + let display = if filtered.trim().is_empty() { + "All packages up-to-date".to_string() } else { - println!("{}", filtered); - } + filtered.clone() + }; + let shown = never_worse(&combined, &display); + println!("{}", shown); - timer.track("pnpm outdated", "rtk pnpm outdated", &combined, &filtered); + timer.track("pnpm outdated", "rtk pnpm outdated", &combined, shown); Ok(0) } @@ -490,9 +494,10 @@ fn run_install(args: &[String], verbose: u8) -> Result { let combined = result.combined(); let filtered = filter_pnpm_install(&combined); - println!("{}", filtered); + let shown = never_worse(&combined, &filtered); + println!("{}", shown); - timer.track("pnpm install", "rtk pnpm install", &combined, &filtered); + timer.track("pnpm install", "rtk pnpm install", &combined, shown); Ok(0) } diff --git a/src/cmds/js/prisma_cmd.rs b/src/cmds/js/prisma_cmd.rs index 2f72313827..3deef1c6f9 100644 --- a/src/cmds/js/prisma_cmd.rs +++ b/src/cmds/js/prisma_cmd.rs @@ -1,5 +1,6 @@ //! Filters Prisma CLI output by stripping ASCII art and verbose decoration. +use crate::core::guard::never_worse; use crate::core::stream::exec_capture; use crate::core::tracking; use crate::core::utils::{resolved_command, tool_exists}; @@ -70,8 +71,9 @@ fn run_generate(args: &[String], verbose: u8) -> Result { } let filtered = filter_prisma_generate(&raw); - println!("{}", filtered); - timer.track("prisma generate", "rtk prisma generate", &raw, &filtered); + let shown = never_worse(&raw, &filtered); + println!("{}", shown); + timer.track("prisma generate", "rtk prisma generate", &raw, shown); Ok(0) } @@ -129,8 +131,9 @@ fn run_migrate(subcommand: MigrateSubcommand, args: &[String], verbose: u8) -> R MigrateSubcommand::Deploy => filter_migrate_deploy(&raw), }; - println!("{}", filtered); - timer.track(cmd_name, &format!("rtk {}", cmd_name), &raw, &filtered); + let shown = never_worse(&raw, &filtered); + println!("{}", shown); + timer.track(cmd_name, &format!("rtk {}", cmd_name), &raw, shown); Ok(0) } @@ -165,8 +168,9 @@ fn run_db_push(args: &[String], verbose: u8) -> Result { } let filtered = filter_db_push(&raw); - println!("{}", filtered); - timer.track("prisma db push", "rtk prisma db push", &raw, &filtered); + let shown = never_worse(&raw, &filtered); + println!("{}", shown); + timer.track("prisma db push", "rtk prisma db push", &raw, shown); Ok(0) } diff --git a/src/cmds/js/vitest_cmd.rs b/src/cmds/js/vitest_cmd.rs index 43b41c59b9..2f6cdbb40c 100644 --- a/src/cmds/js/vitest_cmd.rs +++ b/src/cmds/js/vitest_cmd.rs @@ -256,16 +256,14 @@ pub fn run_test(command: &Commands, args: &[String], verbose: u8) -> Result ); let tee_label = format!("{}_run", framework); - println!( - "{}", - render_test_output(&filtered, &combined, &tee_label, result.exit_code) - ); + let rendered = render_test_output(&filtered, &combined, &tee_label, result.exit_code); + let shown = crate::core::runner::emit_guarded(&rendered, None, &combined); timer.track( format!("{} run", framework).as_str(), format!("rtk {} run", framework).as_str(), &combined, - &filtered.text, + &shown, ); if !result.success() { diff --git a/src/cmds/python/pip_cmd.rs b/src/cmds/python/pip_cmd.rs index 9a4d002713..09737712b8 100644 --- a/src/cmds/python/pip_cmd.rs +++ b/src/cmds/python/pip_cmd.rs @@ -1,5 +1,6 @@ //! Filters pip and uv package manager output. +use crate::core::guard::never_worse; use crate::core::stream::exec_capture; use crate::core::tracking; use crate::core::truncate::{CAP_INVENTORY, CAP_LIST}; @@ -78,7 +79,7 @@ fn run_list(base_cmd: &str, args: &[String], verbose: u8) -> Result<(String, Str let raw = format!("{}\n{}", result.stdout, result.stderr); - let filtered = filter_pip_list(&result.stdout); + let filtered = never_worse(&raw, &filter_pip_list(&result.stdout)).to_string(); println!("{}", filtered); Ok((raw, filtered, result.exit_code)) @@ -106,7 +107,7 @@ fn run_outdated(base_cmd: &str, args: &[String], verbose: u8) -> Result<(String, let raw = format!("{}\n{}", result.stdout, result.stderr); - let filtered = filter_pip_outdated(&result.stdout); + let filtered = never_worse(&raw, &filter_pip_outdated(&result.stdout)).to_string(); println!("{}", filtered); Ok((raw, filtered, result.exit_code)) diff --git a/src/cmds/system/deps.rs b/src/cmds/system/deps.rs index 3cba21139f..055d8cb76f 100644 --- a/src/cmds/system/deps.rs +++ b/src/cmds/system/deps.rs @@ -1,5 +1,6 @@ //! Summarizes project dependencies from lock files and manifests. +use crate::core::guard::never_worse; use crate::core::tracking; use crate::core::truncate::{reduced, CAP_WARNINGS}; use anyhow::Result; @@ -73,8 +74,9 @@ pub fn run(path: &Path, verbose: u8) -> Result<()> { rtk.push_str(&format!("No dependency files found in {}", dir.display())); } - print!("{}", rtk); - timer.track("cat */deps", "rtk deps", &raw, &rtk); + let shown = never_worse(&raw, &rtk); + print!("{}", shown); + timer.track("cat */deps", "rtk deps", &raw, shown); Ok(()) } diff --git a/src/cmds/system/env_cmd.rs b/src/cmds/system/env_cmd.rs index f131b2634f..fae7658f1c 100644 --- a/src/cmds/system/env_cmd.rs +++ b/src/cmds/system/env_cmd.rs @@ -1,5 +1,6 @@ //! Filters environment variables, hiding secrets and noise. +use crate::core::guard::never_worse; use crate::core::tracking; use crate::core::truncate::{CAP_LIST, CAP_WARNINGS}; use anyhow::Result; @@ -64,56 +65,56 @@ pub fn run(filter: Option<&str>, show_all: bool, verbose: u8) -> Result<()> { } } - // Print categorized + let mut body = String::new(); if !path_vars.is_empty() { - println!("PATH Variables:"); + let _ = writeln!(body, "PATH Variables:"); for (k, v) in &path_vars { if k == "PATH" { // Split PATH for readability let paths: Vec<&str> = v.split(':').collect(); - println!(" PATH ({} entries):", paths.len()); + let _ = writeln!(body, " PATH ({} entries):", paths.len()); const MAX_PATH_ENTRIES: usize = CAP_WARNINGS; for p in paths.iter().take(MAX_PATH_ENTRIES) { - println!(" {}", p); + let _ = writeln!(body, " {}", p); } if paths.len() > MAX_PATH_ENTRIES { - println!(" ... +{} more", paths.len() - MAX_PATH_ENTRIES); + let _ = writeln!(body, " ... +{} more", paths.len() - MAX_PATH_ENTRIES); } } else { - println!(" {}={}", k, v); + let _ = writeln!(body, " {}={}", k, v); } } } if !lang_vars.is_empty() { - println!("\nLanguage/Runtime:"); + let _ = writeln!(body, "\nLanguage/Runtime:"); for (k, v) in &lang_vars { - println!(" {}={}", k, v); + let _ = writeln!(body, " {}={}", k, v); } } if !cloud_vars.is_empty() { - println!("\nCloud/Services:"); + let _ = writeln!(body, "\nCloud/Services:"); for (k, v) in &cloud_vars { - println!(" {}={}", k, v); + let _ = writeln!(body, " {}={}", k, v); } } if !tool_vars.is_empty() { - println!("\nTools:"); + let _ = writeln!(body, "\nTools:"); for (k, v) in &tool_vars { - println!(" {}={}", k, v); + let _ = writeln!(body, " {}={}", k, v); } } if !other_vars.is_empty() { const MAX_OTHER_VARS: usize = CAP_LIST; - println!("\nOther:"); + let _ = writeln!(body, "\nOther:"); for (k, v) in other_vars.iter().take(MAX_OTHER_VARS) { - println!(" {}={}", k, v); + let _ = writeln!(body, " {}={}", k, v); } if other_vars.len() > MAX_OTHER_VARS { - println!(" ... +{} more", other_vars.len() - MAX_OTHER_VARS); + let _ = writeln!(body, " ... +{} more", other_vars.len() - MAX_OTHER_VARS); } } @@ -124,15 +125,16 @@ pub fn run(filter: Option<&str>, show_all: bool, verbose: u8) -> Result<()> { + tool_vars.len() + other_vars.len().min(20); if filter.is_none() { - println!("\nTotal: {} vars (showing {} relevant)", total, shown); + let _ = writeln!(body, "\nTotal: {} vars (showing {} relevant)", total, shown); } let raw: String = vars.iter().fold(String::new(), |mut output, (k, v)| { let _ = writeln!(output, "{}={}", k, v); output }); - let rtk = format!("{} vars -> {} shown", total, shown); - timer.track("env", "rtk env", &raw, &rtk); + let shown_body = never_worse(&raw, &body); + print!("{}", shown_body); + timer.track("env", "rtk env", &raw, shown_body); Ok(()) } diff --git a/src/cmds/system/find_cmd.rs b/src/cmds/system/find_cmd.rs index 490619e2fd..0e8d2eebb9 100644 --- a/src/cmds/system/find_cmd.rs +++ b/src/cmds/system/find_cmd.rs @@ -1,5 +1,6 @@ //! Filters find results by grouping files by directory. +use crate::core::guard::never_worse; use crate::core::tracking; use anyhow::{Context, Result}; use ignore::WalkBuilder; @@ -278,13 +279,11 @@ pub fn run( let raw_output = files.join("\n"); if files.is_empty() { - let msg = format!("0 for '{}'", effective_pattern); - println!("{}", msg); timer.track( &format!("find {} -name '{}'", path, effective_pattern), "rtk find", &raw_output, - &msg, + "", ); return Ok(()); } @@ -311,13 +310,14 @@ pub fn run( let dirs_count = dirs.len(); let total_files = files.len(); - println!("{}F {}D:", total_files, dirs_count); - println!(); + let mut body = String::new(); + body.push_str(&format!("{}F {}D:\n", total_files, dirs_count)); + body.push('\n'); // Display with proper --max limiting (count individual files) - let mut shown = 0; + let mut displayed = 0; for dir in &dirs { - if shown >= max_results { + if displayed >= max_results { break; } @@ -328,10 +328,10 @@ pub fn run( dir.clone() }; - let remaining_budget = max_results - shown; + let remaining_budget = max_results - displayed; if files_in_dir.len() <= remaining_budget { - println!("{}/ {}", dir_display, files_in_dir.join(" ")); - shown += files_in_dir.len(); + body.push_str(&format!("{}/ {}\n", dir_display, files_in_dir.join(" "))); + displayed += files_in_dir.len(); } else { // Partial display: show only what fits in budget let partial: Vec<_> = files_in_dir @@ -339,14 +339,14 @@ pub fn run( .take(remaining_budget) .cloned() .collect(); - println!("{}/ {}", dir_display, partial.join(" ")); - shown += partial.len(); + body.push_str(&format!("{}/ {}\n", dir_display, partial.join(" "))); + displayed += partial.len(); break; } } - if shown < total_files { - println!("+{} more", total_files - shown); + if displayed < total_files { + body.push_str(&format!("+{} more\n", total_files - displayed)); } // Extension summary @@ -359,9 +359,8 @@ pub fn run( *by_ext.entry(ext).or_default() += 1; } - let mut ext_line = String::new(); if by_ext.len() > 1 { - println!(); + body.push('\n'); let mut exts: Vec<_> = by_ext.iter().collect(); exts.sort_by(|a, b| b.1.cmp(a.1)); let ext_str: Vec = exts @@ -369,16 +368,17 @@ pub fn run( .take(5) .map(|(e, c)| format!(".{}({})", e, c)) .collect(); - ext_line = format!("ext: {}", ext_str.join(" ")); - println!("{}", ext_line); + let ext_line = format!("ext: {}", ext_str.join(" ")); + body.push_str(&format!("{}\n", ext_line)); } - let rtk_output = format!("{}F {}D + {}", total_files, dirs_count, ext_line); + let shown = never_worse(&raw_output, &body); + print!("{}", shown); timer.track( &format!("find {} -name '{}'", path, effective_pattern), "rtk find", &raw_output, - &rtk_output, + shown, ); Ok(()) diff --git a/src/cmds/system/format_cmd.rs b/src/cmds/system/format_cmd.rs index 431b25c78d..0ebfa3ab45 100644 --- a/src/cmds/system/format_cmd.rs +++ b/src/cmds/system/format_cmd.rs @@ -1,5 +1,6 @@ //! Runs code formatters (Prettier, Ruff) and shows only files that changed. +use crate::core::guard::never_worse; use crate::core::stream::exec_capture; use crate::core::tracking; use crate::core::truncate::CAP_WARNINGS; @@ -124,13 +125,14 @@ pub fn run(args: &[String], verbose: u8) -> Result { _ => raw.trim().to_string(), }; - println!("{}", filtered); + let shown = never_worse(&raw, &filtered); + println!("{}", shown); timer.track( &format!("{} {}", formatter, user_args.join(" ")), &format!("rtk format {} {}", formatter, user_args.join(" ")), &raw, - &filtered, + shown, ); Ok(result.exit_code) diff --git a/src/cmds/system/grep_cmd.rs b/src/cmds/system/grep_cmd.rs index b3954526a1..353b710b38 100644 --- a/src/cmds/system/grep_cmd.rs +++ b/src/cmds/system/grep_cmd.rs @@ -400,13 +400,11 @@ pub fn run( eprintln!("{}", msg); return Ok(exit_code); } - let msg = format!("0 matches for '{}'", pattern_display); - println!("{}", msg); timer.track( &format!("grep -rn '{}' {}", pattern_display, path_display), "rtk grep", &raw_output, - &msg, + "", ); return Ok(exit_code); } @@ -464,12 +462,13 @@ pub fn run( rtk_output.push_str(&format!("[+{} more]\n", total_matches - shown)); } - print!("{}", rtk_output); + let guarded = crate::core::guard::never_worse(&raw_output, &rtk_output); + print!("{}", guarded); timer.track( &format!("grep -rn '{}' {}", pattern_display, path_display), "rtk grep", &raw_output, - &rtk_output, + guarded, ); Ok(exit_code) diff --git a/src/cmds/system/json_cmd.rs b/src/cmds/system/json_cmd.rs index 1482214443..10b59c0d7f 100644 --- a/src/cmds/system/json_cmd.rs +++ b/src/cmds/system/json_cmd.rs @@ -1,5 +1,6 @@ //! Inspects JSON structure without showing values, saving tokens on large payloads. +use crate::core::guard::never_worse; use crate::core::tracking; use anyhow::{bail, Context, Result}; use serde_json::Value; @@ -52,12 +53,13 @@ pub fn run(file: &Path, max_depth: usize, schema_only: bool, verbose: u8) -> Res } else { filter_json_compact(&content, max_depth)? }; - println!("{}", output); + let shown = never_worse(&content, &output); + println!("{}", shown); timer.track( &format!("cat {}", file.display()), "rtk json", &content, - &output, + shown, ); Ok(()) } @@ -81,8 +83,9 @@ pub fn run_stdin(max_depth: usize, schema_only: bool, verbose: u8) -> Result<()> } else { filter_json_compact(&content, max_depth)? }; - println!("{}", output); - timer.track("cat - (stdin)", "rtk json -", &content, &output); + let shown = never_worse(&content, &output); + println!("{}", shown); + timer.track("cat - (stdin)", "rtk json -", &content, shown); Ok(()) } diff --git a/src/cmds/system/log_cmd.rs b/src/cmds/system/log_cmd.rs index 7c765f5e6e..3dca37452f 100644 --- a/src/cmds/system/log_cmd.rs +++ b/src/cmds/system/log_cmd.rs @@ -1,5 +1,6 @@ //! Deduplicates repeated log lines and shows counts instead. +use crate::core::guard::never_worse; use crate::core::tracking; use crate::core::truncate::{reduced, CAP_WARNINGS}; use anyhow::Result; @@ -31,12 +32,13 @@ pub fn run_file(file: &Path, verbose: u8) -> Result<()> { let content = fs::read_to_string(file)?; let result = analyze_logs(&content); - println!("{}", result); + let shown = never_worse(&content, &result); + println!("{}", shown); timer.track( &format!("cat {}", file.display()), "rtk log", &content, - &result, + shown, ); Ok(()) } @@ -53,9 +55,10 @@ pub fn run_stdin(_verbose: u8) -> Result<()> { } let result = analyze_logs(&content); - println!("{}", result); + let shown = never_worse(&content, &result); + println!("{}", shown); - timer.track("log (stdin)", "rtk log (stdin)", &content, &result); + timer.track("log (stdin)", "rtk log (stdin)", &content, shown); Ok(()) } diff --git a/src/cmds/system/read.rs b/src/cmds/system/read.rs index 2f56687ee3..5fb716d538 100644 --- a/src/cmds/system/read.rs +++ b/src/cmds/system/read.rs @@ -1,6 +1,7 @@ //! Reads source files with optional language-aware filtering to strip boilerplate. use crate::core::filter::{self, FilterLevel, Language}; +use crate::core::guard::never_worse; use crate::core::tracking; use anyhow::{Context, Result}; use std::fs; @@ -70,12 +71,13 @@ pub fn run( } else { filtered.clone() }; - print!("{}", rtk_output); + let shown = never_worse(&content, &rtk_output); + print!("{}", shown); timer.track( &format!("cat {}", file.display()), "rtk read", &content, - &rtk_output, + shown, ); Ok(()) } @@ -134,9 +136,10 @@ pub fn run_stdin( } else { filtered.clone() }; - print!("{}", rtk_output); + let shown = never_worse(&content, &rtk_output); + print!("{}", shown); - timer.track("cat - (stdin)", "rtk read -", &content, &rtk_output); + timer.track("cat - (stdin)", "rtk read -", &content, shown); Ok(()) } diff --git a/src/cmds/system/summary.rs b/src/cmds/system/summary.rs index 31de537ea0..5fdb0c6716 100644 --- a/src/cmds/system/summary.rs +++ b/src/cmds/system/summary.rs @@ -1,5 +1,6 @@ //! Runs a command and produces a heuristic summary of its output. +use crate::core::guard::never_worse; use crate::core::stream::exec_capture; use crate::core::tracking; use crate::core::truncate::CAP_WARNINGS; @@ -33,8 +34,9 @@ pub fn run(command: &str, verbose: u8) -> Result { let raw = format!("{}\n{}", result.stdout, result.stderr); let summary = summarize_output(&raw, command, result.success()); - println!("{}", summary); - timer.track(command, "rtk summary", &raw, &summary); + let shown = never_worse(&raw, &summary); + println!("{}", shown); + timer.track(command, "rtk summary", &raw, shown); Ok(result.exit_code) } diff --git a/src/core/guard.rs b/src/core/guard.rs new file mode 100644 index 0000000000..b8b905b7b2 --- /dev/null +++ b/src/core/guard.rs @@ -0,0 +1,56 @@ +//! Never-worse output guard: RTK never emits more tokens than the raw command. + +use crate::core::tracking::estimate_tokens; + +/// Returns `filtered`, or `raw` when `filtered` would emit more tokens. +pub fn never_worse<'a>(raw: &'a str, filtered: &'a str) -> &'a str { + if estimate_tokens(filtered) > estimate_tokens(raw) { + raw + } else { + filtered + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keeps_filtered_when_smaller() { + let raw = "a".repeat(400); + assert_eq!(never_worse(&raw, "ok"), "ok"); + } + + #[test] + fn falls_back_to_raw_when_filtered_bigger() { + let raw = "{}"; + let filtered = "{\n \"pretty\": true\n}"; + assert_eq!(never_worse(raw, filtered), raw); + } + + #[test] + fn tie_keeps_filtered() { + assert_eq!(never_worse("abcd", "wxyz"), "wxyz"); + } + + #[test] + fn token_boundary_follows_estimate_tokens() { + assert_eq!(never_worse("abcd", "abcde"), "abcd"); + assert_eq!(never_worse("abcdefgh", "ijklmnop"), "ijklmnop"); + } + + #[test] + fn empty_raw_returns_raw() { + assert_eq!(never_worse("", "0 matches"), ""); + } + + #[test] + fn empty_filtered_returns_filtered() { + assert_eq!(never_worse("data", ""), ""); + } + + #[test] + fn both_empty_returns_filtered() { + assert_eq!(never_worse("", ""), ""); + } +} diff --git a/src/core/mod.rs b/src/core/mod.rs index d5182bd340..aaa747e083 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -5,6 +5,7 @@ pub mod config; pub mod constants; pub mod display_helpers; pub mod filter; +pub mod guard; pub mod runner; pub mod stream; pub mod tee; diff --git a/src/core/runner.rs b/src/core/runner.rs index 571c7634b2..b893357491 100644 --- a/src/core/runner.rs +++ b/src/core/runner.rs @@ -6,12 +6,28 @@ use std::process::Command; use crate::core::stream::{self, FilterMode, StdinMode, StreamFilter}; use crate::core::tracking; -pub fn print_with_hint(filtered: &str, raw: &str, tee_label: &str, exit_code: i32) { - if let Some(hint) = crate::core::tee::tee_and_hint(raw, tee_label, exit_code) { - println!("{}\n{}", filtered, hint); - } else { - println!("{}", filtered); - } +/// Compose `filtered` with an optional recovery `hint`, cap the total at `raw` +/// (never emit more tokens than the command), print it, and return what was +/// emitted so the caller tracks exactly that. +pub fn emit_guarded(filtered: &str, hint: Option<&str>, raw: &str) -> String { + let body = match hint { + Some(h) => format!("{}\n{}", filtered, h), + None => filtered.to_string(), + }; + let shown = crate::core::guard::never_worse(raw, &body).to_string(); + println!("{}", shown); + shown +} + +pub fn print_with_hint( + filtered: &str, + tee_raw: &str, + guard_raw: &str, + tee_label: &str, + exit_code: i32, +) -> String { + let hint = crate::core::tee::tee_and_hint(tee_raw, tee_label, exit_code); + emit_guarded(filtered, hint.as_deref(), guard_raw) } #[derive(Default)] @@ -113,24 +129,29 @@ where }; let filtered = filter_fn(text_to_filter, exit_code); - if let Some(label) = opts.tee_label { - print_with_hint(&filtered, raw, label, exit_code); - } else if opts.no_trailing_newline { - print!("{}", filtered); - } else { - println!("{}", filtered); - } - let raw_for_tracking = if opts.filter_stdout_only { raw_stdout } else { raw }; + + let shown = if let Some(label) = opts.tee_label { + print_with_hint(&filtered, raw, raw_for_tracking, label, exit_code) + } else { + let guarded = crate::core::guard::never_worse(raw_for_tracking, &filtered).to_string(); + if opts.no_trailing_newline { + print!("{}", guarded); + } else { + println!("{}", guarded); + } + guarded + }; + timer.track( cmd_label, &format!("rtk {}", cmd_label), raw_for_tracking, - &filtered, + &shown, ); Ok(exit_code) } diff --git a/src/main.rs b/src/main.rs index c55751dfc0..5a9e88dcb9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1268,16 +1268,14 @@ fn run_fallback(parse_error: clap::Error) -> Result { }; let filtered = core::toml_filter::apply_filter(filter, &combined_raw); - println!("{}", filtered); - if let Some(hint) = tee_hint { - println!("{}", hint); - } + let shown = + core::runner::emit_guarded(&filtered, tee_hint.as_deref(), &combined_raw); timer.track( &raw_command, &format!("rtk:toml {}", raw_command), &combined_raw, - &filtered, + &shown, ); core::tracking::record_parse_failure_silent(&raw_command, &error_message, true); diff --git a/tests/guard_integration_test.rs b/tests/guard_integration_test.rs new file mode 100644 index 0000000000..494ac05383 --- /dev/null +++ b/tests/guard_integration_test.rs @@ -0,0 +1,151 @@ +//! End-to-end proof of the never-worse guard (src/core/guard.rs). + +use std::io::Write; +use std::process::{Command, Stdio}; + +fn rtk_stdin(args: &[&str], input: &str) -> String { + let mut child = Command::new(env!("CARGO_BIN_EXE_rtk")) + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn rtk"); + child + .stdin + .take() + .expect("stdin") + .write_all(input.as_bytes()) + .expect("write stdin"); + let out = child.wait_with_output().expect("wait rtk"); + String::from_utf8_lossy(&out.stdout).into_owned() +} + +#[test] +fn guard_shows_raw_when_filter_would_bloat_tiny_input() { + let input = "{\"a\":1,\"b\":2,\"c\":3,\"d\":4}"; + let out = rtk_stdin(&["json", "-"], input); + + assert_eq!( + out.trim(), + input, + "guard should emit the raw minified JSON, not a larger pretty-printed form" + ); + assert!( + out.trim().len() <= input.len(), + "never-worse violated: {} chars emitted for a {}-char raw input", + out.trim().len(), + input.len() + ); +} + +#[test] +fn guard_does_not_block_real_compression() { + let mut input = String::from("{"); + for i in 0..60 { + input.push_str(&format!("\"key_{i}\":\"value_{i}\",")); + } + input.push_str("\"last\":1}"); + + let out = rtk_stdin(&["json", "-"], &input); + assert!( + out.len() < input.len(), + "filter should compress large input (guard must not over-trigger): {} vs {}", + out.len(), + input.len() + ); +} + +fn rtk_in_dir(dir: &std::path::Path, args: &[&str]) -> (String, Option) { + let out = Command::new(env!("CARGO_BIN_EXE_rtk")) + .args(args) + .current_dir(dir) + .stderr(Stdio::null()) + .output() + .expect("spawn rtk"); + ( + String::from_utf8_lossy(&out.stdout).into_owned(), + out.status.code(), + ) +} + +fn rg_available() -> bool { + Command::new("rg") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +fn init_git_repo() -> tempfile::TempDir { + let dir = tempfile::tempdir().expect("tempdir"); + for args in [ + &["init", "-q"][..], + &["config", "user.email", "t@t.t"][..], + &["config", "user.name", "t"][..], + &["commit", "-q", "--allow-empty", "-m", "init"][..], + ] { + let ok = Command::new("git") + .args(args) + .current_dir(dir.path()) + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + assert!(ok, "git setup failed: {args:?}"); + } + dir +} + +#[test] +fn grep_no_match_emits_empty_not_a_message() { + if !rg_available() { + return; + } + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write(dir.path().join("a.txt"), "hello world\n").expect("write"); + let (out, code) = rtk_in_dir(dir.path(), &["grep", "zzz_no_match_xyz", "."]); + assert!( + out.trim().is_empty(), + "no-match grep must emit empty, not a '0 matches' line: {out:?}" + ); + assert_eq!(code, Some(1), "grep no-match must preserve exit 1"); +} + +#[test] +fn find_no_results_emits_empty() { + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write(dir.path().join("a.txt"), "x").expect("write"); + let (out, _) = rtk_in_dir(dir.path(), &["find", ".", "-name", "zzz_no_match_xyz"]); + assert!( + out.trim().is_empty(), + "no-result find must emit empty, not a '0 for' line: {out:?}" + ); +} + +#[test] +fn git_stash_list_no_stashes_emits_empty() { + let dir = init_git_repo(); + let (out, code) = rtk_in_dir(dir.path(), &["git", "stash", "list"]); + assert!( + out.trim().is_empty(), + "no-stashes must emit empty, not 'No stashes': {out:?}" + ); + assert_eq!(code, Some(0)); +} + +#[test] +fn git_stash_show_no_stash_emits_empty_and_propagates_failure() { + // Regression: previously printed "Empty stash" and returned Ok(0), masking + // the underlying `git stash show` failure. + let dir = init_git_repo(); + let (out, code) = rtk_in_dir(dir.path(), &["git", "stash", "show"]); + assert!( + out.trim().is_empty(), + "must emit empty, not 'Empty stash': {out:?}" + ); + assert_ne!( + code, + Some(0), + "a real git stash show failure must not be masked as exit 0" + ); +} From 223dda2996c061e93605d996a14d454a56198ec4 Mon Sep 17 00:00:00 2001 From: Adrien Eppling Date: Wed, 24 Jun 2026 11:18:32 +0200 Subject: [PATCH 32/50] fix(env): clean up feature from secrets rewrite Masking env secrets is not what core RTK (OSS) is for -- it compresses command output, it does not redact what the command itself already reveals. It also broke raw-vs-filtered consistency, which the never-worse guard relies on. --- src/cmds/system/env_cmd.rs | 79 ++------------------------------------ src/main.rs | 9 ++--- 2 files changed, 7 insertions(+), 81 deletions(-) diff --git a/src/cmds/system/env_cmd.rs b/src/cmds/system/env_cmd.rs index fae7658f1c..e2cea8a205 100644 --- a/src/cmds/system/env_cmd.rs +++ b/src/cmds/system/env_cmd.rs @@ -1,22 +1,20 @@ -//! Filters environment variables, hiding secrets and noise. +//! Filters environment variables, compacting noise. use crate::core::guard::never_worse; use crate::core::tracking; use crate::core::truncate::{CAP_LIST, CAP_WARNINGS}; use anyhow::Result; -use std::collections::HashSet; use std::env; use std::fmt::Write; -/// Show filtered environment variables (hide sensitive data) -pub fn run(filter: Option<&str>, show_all: bool, verbose: u8) -> Result<()> { +/// Show filtered environment variables +pub fn run(filter: Option<&str>, verbose: u8) -> Result<()> { let timer = tracking::TimedExecution::start(); if verbose > 0 { eprintln!("Environment variables:"); } - let sensitive_patterns = get_sensitive_patterns(); let mut vars: Vec<(String, String)> = env::vars().collect(); vars.sort_by(|a, b| a.0.cmp(&b.0)); @@ -35,14 +33,7 @@ pub fn run(filter: Option<&str>, show_all: bool, verbose: u8) -> Result<()> { } } - // Check if sensitive - let is_sensitive = sensitive_patterns - .iter() - .any(|p| key.to_lowercase().contains(p)); - - let display_value = if is_sensitive && !show_all { - mask_value(value) - } else if value.len() > 100 { + let display_value = if value.len() > 100 { let preview: String = value.chars().take(50).collect(); format!("{}... ({} chars)", preview, value.chars().count()) } else { @@ -138,33 +129,6 @@ pub fn run(filter: Option<&str>, show_all: bool, verbose: u8) -> Result<()> { Ok(()) } -fn get_sensitive_patterns() -> HashSet<&'static str> { - let mut set = HashSet::new(); - set.insert("key"); - set.insert("secret"); - set.insert("password"); - set.insert("token"); - set.insert("credential"); - set.insert("auth"); - set.insert("private"); - set.insert("api_key"); - set.insert("apikey"); - set.insert("access_key"); - set.insert("jwt"); - set -} - -fn mask_value(value: &str) -> String { - let chars: Vec = value.chars().collect(); - if chars.len() <= 4 { - "****".to_string() - } else { - let prefix: String = chars[..2].iter().collect(); - let suffix: String = chars[chars.len() - 2..].iter().collect(); - format!("{}****{}", prefix, suffix) - } -} - fn is_lang_var(key: &str) -> bool { let patterns = [ "RUST", "CARGO", "PYTHON", "PIP", "NODE", "NPM", "YARN", "DENO", "BUN", "JAVA", "MAVEN", @@ -218,32 +182,6 @@ fn is_interesting_var(key: &str) -> bool { mod tests { use super::*; - #[test] - fn test_mask_value_short() { - assert_eq!(mask_value("abc"), "****"); - assert_eq!(mask_value(""), "****"); - } - - #[test] - fn test_mask_value_long() { - let result = mask_value("supersecrettoken"); - assert!(result.contains("****"), "Masked value should contain ****"); - assert!(result.starts_with("su"), "Should preserve 2-char prefix"); - assert!(result.ends_with("en"), "Should preserve 2-char suffix"); - } - - #[test] - fn test_mask_value_exactly_four() { - assert_eq!(mask_value("abcd"), "****"); - } - - #[test] - fn test_mask_value_five_chars() { - let result = mask_value("abcde"); - assert!(result.starts_with("ab")); - assert!(result.ends_with("de")); - } - #[test] fn test_is_lang_var_rust() { assert!(is_lang_var("RUST_LOG")); @@ -295,13 +233,4 @@ mod tests { assert!(!is_interesting_var("RANDOM_VAR")); assert!(!is_interesting_var("MY_CUSTOM_VAR")); } - - #[test] - fn test_sensitive_patterns_contains_keys() { - let patterns = get_sensitive_patterns(); - assert!(patterns.contains("key")); - assert!(patterns.contains("secret")); - assert!(patterns.contains("password")); - assert!(patterns.contains("token")); - } } diff --git a/src/main.rs b/src/main.rs index 5a9e88dcb9..d919afe8e3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -244,14 +244,11 @@ enum Commands { path: PathBuf, }, - /// Show environment variables (filtered, sensitive masked) + /// Show environment variables (filtered) Env { /// Filter by name (e.g. PATH, AWS) #[arg(short, long)] filter: Option, - /// Show all (include sensitive) - #[arg(long)] - show_all: bool, }, /// Find files with compact tree output (accepts native find flags like -name, -type) @@ -1751,8 +1748,8 @@ fn run_cli() -> Result { 0 } - Commands::Env { filter, show_all } => { - env_cmd::run(filter.as_deref(), show_all, cli.verbose)?; + Commands::Env { filter } => { + env_cmd::run(filter.as_deref(), cli.verbose)?; 0 } From 9d9ad7cb177a1da22c4395dc6716fdf5e640a6f3 Mon Sep 17 00:00:00 2001 From: Adrien Eppling Date: Wed, 24 Jun 2026 14:32:25 +0200 Subject: [PATCH 33/50] fix(pipe): apply the never-worse guard pipe filters piped stdin but bypassed the guard; route its output through never_worse like every other compressor. --- src/cmds/system/pipe_cmd.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/cmds/system/pipe_cmd.rs b/src/cmds/system/pipe_cmd.rs index 6dcc4cdb61..c1a1a07c87 100644 --- a/src/cmds/system/pipe_cmd.rs +++ b/src/cmds/system/pipe_cmd.rs @@ -1,6 +1,7 @@ use anyhow::Result; use std::io::Read; +use crate::core::guard::never_worse; use crate::core::stream::RAW_CAP; use crate::core::truncate::{CAP_LIST, CAP_WARNINGS}; @@ -238,7 +239,8 @@ pub fn run(filter_name: Option<&str>, passthrough: bool) -> Result<()> { }; let output = apply_filter(filter_fn, &buf); - print!("{}", output); + let shown = never_worse(&buf, &output); + print!("{}", shown); Ok(()) } From 9a2ad90360b39ba8786f6056188ee9a939a9db28 Mon Sep 17 00:00:00 2001 From: Adrien Eppling Date: Wed, 24 Jun 2026 16:43:54 +0200 Subject: [PATCH 34/50] fix(read): make guard baseline faithful to cat/cat -n output Compare filtered output against the raw the agent's command would emit (numbered for -n), not against unnumbered content, so the guard keeps requested line numbers instead of dropping to plain. --- src/cmds/system/read.rs | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/src/cmds/system/read.rs b/src/cmds/system/read.rs index 5fb716d538..141d55acf2 100644 --- a/src/cmds/system/read.rs +++ b/src/cmds/system/read.rs @@ -66,17 +66,20 @@ pub fn run( filtered = apply_line_window(&filtered, max_lines, tail_lines, &lang); - let rtk_output = if line_numbers { - format_with_line_numbers(&filtered) + let (raw, rtk_output) = if line_numbers { + ( + format_with_line_numbers(&content), + format_with_line_numbers(&filtered), + ) } else { - filtered.clone() + (content.clone(), filtered.clone()) }; - let shown = never_worse(&content, &rtk_output); + let shown = never_worse(&raw, &rtk_output); print!("{}", shown); timer.track( &format!("cat {}", file.display()), "rtk read", - &content, + &raw, shown, ); Ok(()) @@ -131,15 +134,18 @@ pub fn run_stdin( filtered = apply_line_window(&filtered, max_lines, tail_lines, &lang); - let rtk_output = if line_numbers { - format_with_line_numbers(&filtered) + let (raw, rtk_output) = if line_numbers { + ( + format_with_line_numbers(&content), + format_with_line_numbers(&filtered), + ) } else { - filtered.clone() + (content.clone(), filtered.clone()) }; - let shown = never_worse(&content, &rtk_output); + let shown = never_worse(&raw, &rtk_output); print!("{}", shown); - timer.track("cat - (stdin)", "rtk read -", &content, shown); + timer.track("cat - (stdin)", "rtk read -", &raw, shown); Ok(()) } From b52db52da065f65f9838e7fd12c496b7d28d06e8 Mon Sep 17 00:00:00 2001 From: Adrien Eppling Date: Wed, 24 Jun 2026 18:44:42 +0200 Subject: [PATCH 35/50] fix(docker): make the agent's command authoritative for the guard baseline docker ps/ps -a/images ran a plain command for the raw baseline and a separate --format command for parsing, capturing the plain run with unwrap_or_default(). A failed plain run while --format succeeded left raw empty, and never_worse("", summary) dropped real output. Run the agent's actual command first as the source of truth: on failure show its output/exit faithfully; on success use its stdout as raw and treat --format as a best-effort parse aid that falls back to raw. The guard stays pure and always sees a real baseline. Parsers unchanged. --- src/cmds/cloud/container.rs | 97 +++++++++++++++++++++++-------------- 1 file changed, 61 insertions(+), 36 deletions(-) diff --git a/src/cmds/cloud/container.rs b/src/cmds/cloud/container.rs index 10f2ce1c8d..59c1ee5f90 100644 --- a/src/cmds/cloud/container.rs +++ b/src/cmds/cloud/container.rs @@ -58,24 +58,32 @@ where fn docker_ps(_verbose: u8) -> Result { let timer = tracking::TimedExecution::start(); - let raw = exec_capture(resolved_command("docker").args(["ps"])) - .map(|r| r.stdout) - .unwrap_or_default(); + let base = exec_capture(resolved_command("docker").args(["ps"])) + .context("Failed to run docker ps")?; + if !base.success() { + eprint!("{}", base.stderr); + print!("{}", base.stdout); + timer.track("docker ps", "rtk docker ps", &base.stdout, &base.stdout); + return Ok(base.exit_code); + } + let raw = base.stdout; - let result = exec_capture(resolved_command("docker").args([ + let stdout = match exec_capture(resolved_command("docker").args([ "ps", "--format", "{{.ID}}\t{{.Names}}\t{{.Status}}\t{{.Image}}\t{{.Ports}}", ])) - .context("Failed to run docker ps")?; - - if !result.success() { - eprint!("{}", result.stderr); - timer.track("docker ps", "rtk docker ps", &raw, &raw); - return Ok(result.exit_code); - } + .ok() + .filter(|r| r.success()) + { + Some(r) => r.stdout, + None => { + print!("{}", raw); + timer.track("docker ps", "rtk docker ps", &raw, &raw); + return Ok(0); + } + }; - let stdout = result.stdout; let mut rtk = String::new(); if stdout.trim().is_empty() { @@ -111,27 +119,36 @@ fn docker_ps(_verbose: u8) -> Result { fn docker_ps_all(_verbose: u8) -> Result { let timer = tracking::TimedExecution::start(); - let raw = exec_capture(resolved_command("docker").args(["ps", "-a"])) - .map(|r| r.stdout) - .unwrap_or_default(); + let base = exec_capture(resolved_command("docker").args(["ps", "-a"])) + .context("Failed to run docker ps -a")?; + if !base.success() { + eprint!("{}", base.stderr); + print!("{}", base.stdout); + timer.track("docker ps -a", "rtk docker ps -a", &base.stdout, &base.stdout); + return Ok(base.exit_code); + } + let raw = base.stdout; - let result = exec_capture(resolved_command("docker").args([ + let stdout = match exec_capture(resolved_command("docker").args([ "ps", "-a", "--format", "{{.State}}\t{{.ID}}\t{{.Names}}\t{{.Status}}\t{{.Image}}\t{{.Ports}}", ])) - .context("Failed to run docker ps -a")?; - - if !result.success() { - eprint!("{}", result.stderr); - timer.track("docker ps -a", "rtk docker ps -a", &raw, &raw); - return Ok(result.exit_code); - } + .ok() + .filter(|r| r.success()) + { + Some(r) => r.stdout, + None => { + print!("{}", raw); + timer.track("docker ps -a", "rtk docker ps -a", &raw, &raw); + return Ok(0); + } + }; let mut running_lines: Vec = Vec::new(); let mut stopped_lines: Vec = Vec::new(); - for line in result.stdout.lines().filter(|l| !l.trim().is_empty()) { + for line in stdout.lines().filter(|l| !l.trim().is_empty()) { let parts: Vec<&str> = line.split('\t').collect(); let state = parts.first().copied().unwrap_or(""); let is_running = matches!(state, "running" | "restarting"); @@ -218,24 +235,32 @@ fn format_container_line_from_parts(parts: &[&str], with_ports: bool) -> Option< fn docker_images(_verbose: u8) -> Result { let timer = tracking::TimedExecution::start(); - let raw = exec_capture(resolved_command("docker").args(["images"])) - .map(|r| r.stdout) - .unwrap_or_default(); + let base = exec_capture(resolved_command("docker").args(["images"])) + .context("Failed to run docker images")?; + if !base.success() { + eprint!("{}", base.stderr); + print!("{}", base.stdout); + timer.track("docker images", "rtk docker images", &base.stdout, &base.stdout); + return Ok(base.exit_code); + } + let raw = base.stdout; - let result = exec_capture(resolved_command("docker").args([ + let stdout = match exec_capture(resolved_command("docker").args([ "images", "--format", "{{.Repository}}:{{.Tag}}\t{{.Size}}", ])) - .context("Failed to run docker images")?; - - if !result.success() { - eprint!("{}", result.stderr); - timer.track("docker images", "rtk docker images", &raw, &raw); - return Ok(result.exit_code); - } + .ok() + .filter(|r| r.success()) + { + Some(r) => r.stdout, + None => { + print!("{}", raw); + timer.track("docker images", "rtk docker images", &raw, &raw); + return Ok(0); + } + }; - let stdout = result.stdout; let lines: Vec<&str> = stdout.lines().collect(); let mut rtk = String::new(); From 25a095e90d7f8320d80577177f7623b13fa1bbad Mon Sep 17 00:00:00 2001 From: Adrien Eppling Date: Wed, 24 Jun 2026 19:27:06 +0200 Subject: [PATCH 36/50] fix(aws): guard the s3 ls and s3 sync/cp text emits --- src/cmds/cloud/aws_cmd.rs | 30 ++++++++++++------------------ 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/src/cmds/cloud/aws_cmd.rs b/src/cmds/cloud/aws_cmd.rs index 0f8287af00..fa76d73124 100644 --- a/src/cmds/cloud/aws_cmd.rs +++ b/src/cmds/cloud/aws_cmd.rs @@ -407,17 +407,14 @@ fn run_s3_ls(extra_args: &[String], verbose: u8) -> Result { } let result = filter_s3_ls(&stdout); - if result.truncated { - if let Some(hint) = crate::core::tee::force_tee_hint(&raw, "aws_s3_ls") { - println!("{}\n{}", result.text, hint); - } else { - println!("{}", result.text); - } + let hint = if result.truncated { + crate::core::tee::force_tee_hint(&raw, "aws_s3_ls") } else { - println!("{}", result.text); - } + None + }; + let shown = crate::core::runner::emit_guarded(&result.text, hint.as_deref(), &raw); - timer.track("aws s3 ls", "rtk aws s3 ls", &raw, &result.text); + timer.track("aws s3 ls", "rtk aws s3 ls", &raw, &shown); Ok(0) } @@ -460,17 +457,14 @@ fn run_s3_transfer(operation: &str, extra_args: &[String], verbose: u8) -> Resul } let result = filter_s3_transfer(&stdout); - if result.truncated { - if let Some(hint) = force_tee_hint(&raw, &slug) { - println!("{}\n{}", result.text, hint); - } else { - println!("{}", result.text); - } + let hint = if result.truncated { + force_tee_hint(&raw, &slug) } else { - println!("{}", result.text); - } + None + }; + let shown = crate::core::runner::emit_guarded(&result.text, hint.as_deref(), &raw); - timer.track(&cmd_label, &rtk_label, &raw, &result.text); + timer.track(&cmd_label, &rtk_label, &raw, &shown); Ok(0) } From 3d4189cc6304a8eefc7ab94388b268054b94a634 Mon Sep 17 00:00:00 2001 From: Adrien Eppling Date: Wed, 24 Jun 2026 20:37:02 +0200 Subject: [PATCH 37/50] fix(docker): report 0 containers/images instead of empty output --- src/cmds/cloud/container.rs | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/cmds/cloud/container.rs b/src/cmds/cloud/container.rs index 59c1ee5f90..348ab14f55 100644 --- a/src/cmds/cloud/container.rs +++ b/src/cmds/cloud/container.rs @@ -86,11 +86,6 @@ fn docker_ps(_verbose: u8) -> Result { let mut rtk = String::new(); - if stdout.trim().is_empty() { - timer.track("docker ps", "rtk docker ps", &raw, ""); - return Ok(0); - } - const MAX_CONTAINERS: usize = CAP_LIST; let lines: Vec = stdout .lines() @@ -264,11 +259,6 @@ fn docker_images(_verbose: u8) -> Result { let lines: Vec<&str> = stdout.lines().collect(); let mut rtk = String::new(); - if lines.is_empty() { - timer.track("docker images", "rtk docker images", &raw, ""); - return Ok(0); - } - let mut total_size_mb: f64 = 0.0; for line in &lines { let parts: Vec<&str> = line.split('\t').collect(); From 5a344524e35bbcf2ef6e8132c74268429e035099 Mon Sep 17 00:00:00 2001 From: Adrien Eppling Date: Wed, 24 Jun 2026 20:37:04 +0200 Subject: [PATCH 38/50] test(bench): drop env --show-all (flag removed) --- scripts/benchmark.sh | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/benchmark.sh b/scripts/benchmark.sh index 0af7e44179..4eecf4f177 100755 --- a/scripts/benchmark.sh +++ b/scripts/benchmark.sh @@ -248,7 +248,6 @@ bench "deps" "cat Cargo.toml" "$RTK deps" section "env" bench "env" "env" "$RTK env" bench "env -f PATH" "env | grep PATH" "$RTK env -f PATH" -bench "env --show-all" "env" "$RTK env --show-all" # =================== # err From ee5a675f40cd493e4cbcc23573aaf6f59c6e6951 Mon Sep 17 00:00:00 2001 From: Husam Date: Fri, 26 Jun 2026 11:35:00 +0300 Subject: [PATCH 39/50] refactor(git): drop explanatory comment on status failure guard Per review (#2498): the guard is self-explanatory; remove the comment to avoid noise. --- src/cmds/git/git.rs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index a4b16f18ed..a2a232f314 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -862,12 +862,6 @@ fn run_status(args: &[String], verbose: u8, global_args: &[String]) -> Result Date: Fri, 26 Jun 2026 11:35:37 +0300 Subject: [PATCH 40/50] refactor(git): drop explanatory comment on worktree list failure guard Per review (#2498): self-explanatory guard; remove comment to avoid noise. --- src/cmds/git/git.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index c727a783de..c4f1b8ceda 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -1714,9 +1714,6 @@ fn run_worktree(args: &[String], verbose: u8, global_args: &[String]) -> Result< cmd.args(["worktree", "list"]); let result = exec_capture(&mut cmd).context("Failed to run git worktree list")?; - // A failed `git worktree list` (e.g. run outside a repo) must propagate, not - // be flattened to empty output + exit 0. The has_action branch above already - // guards on success (#2497). if !result.success() { eprintln!("FAILED: git worktree list"); if !result.stderr.trim().is_empty() { From 8ef08cf6f054fa202d2f2469ee4bda1d1583a9cb Mon Sep 17 00:00:00 2001 From: Husam Date: Fri, 26 Jun 2026 12:04:25 +0300 Subject: [PATCH 41/50] refactor(git): trim verbose comments on commit outcome to a concise note --- src/cmds/git/git.rs | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index cbc7660a9e..94d7eaec6f 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -1030,9 +1030,6 @@ fn run_commit(args: &[String], verbose: u8, global_args: &[String]) -> Result { eprintln!("FAILED: git commit"); if !stderr.trim().is_empty() { @@ -1047,10 +1044,8 @@ fn run_commit(args: &[String], verbose: u8, global_args: &[String]) -> Result assert_eq!(code, 2), CommitOutcome::Ok(_) => panic!("hook abort must be a failure"), From a271b5e003fd2d7cd96a1a9b9a1195c61e7602b4 Mon Sep 17 00:00:00 2001 From: Husam Date: Fri, 26 Jun 2026 12:10:00 +0300 Subject: [PATCH 42/50] chore: re-trigger CI (transient crates.io download failure on windows runner) From c04dec1fe1596a008e266b753374ab330202adde Mon Sep 17 00:00:00 2001 From: Husam Date: Fri, 26 Jun 2026 13:16:24 +0300 Subject: [PATCH 43/50] test(git): cover exit-code propagation for git stash list/show failure develop already propagates the exit code for failed git stash list/show (via the empty-stdout guard). Add regression tests so the masking-failure behavior (#2497) can't creep back. --- src/cmds/git/git.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index 322a577307..03d2b2f8d5 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -2077,6 +2077,22 @@ mod tests { assert!(result.contains("stash@{1}: def5678 wip")); } + #[test] + fn test_run_stash_list_propagates_failure() { + let dir = tempfile::tempdir().expect("tempdir"); + let global = vec!["-C".to_string(), dir.path().to_string_lossy().into_owned()]; + let code = run_stash(Some("list"), &[], 0, &global).expect("run_stash list"); + assert_ne!(code, 0, "git stash list failure must propagate"); + } + + #[test] + fn test_run_stash_show_propagates_failure() { + let dir = tempfile::tempdir().expect("tempdir"); + let global = vec!["-C".to_string(), dir.path().to_string_lossy().into_owned()]; + let code = run_stash(Some("show"), &[], 0, &global).expect("run_stash show"); + assert_ne!(code, 0, "git stash show failure must propagate"); + } + #[test] fn test_filter_worktree_list() { let output = From adaf2b259c5ff33920254fa79cde4f2b18400348 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Fri, 26 Jun 2026 13:13:37 +0200 Subject: [PATCH 44/50] Update git.rs --- src/cmds/git/git.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index c4f1b8ceda..98f8a020d5 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -1715,7 +1715,6 @@ fn run_worktree(args: &[String], verbose: u8, global_args: &[String]) -> Result< let result = exec_capture(&mut cmd).context("Failed to run git worktree list")?; if !result.success() { - eprintln!("FAILED: git worktree list"); if !result.stderr.trim().is_empty() { eprintln!("{}", result.stderr); } From a9b2ef5b3c91081426df8ae05cc107b89b7a57bc Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Fri, 26 Jun 2026 13:16:32 +0200 Subject: [PATCH 45/50] Update git.rs --- src/cmds/git/git.rs | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index 94d7eaec6f..2e6d23a86d 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -1031,7 +1031,6 @@ fn run_commit(args: &[String], verbose: u8, global_args: &[String]) -> Result { - eprintln!("FAILED: git commit"); if !stderr.trim().is_empty() { eprint!("{}", stderr); } @@ -1047,19 +1046,14 @@ fn run_commit(args: &[String], verbose: u8, global_args: &[String]) -> Result" -/// line; any failure (including "nothing to commit", which git reports with a -/// non-zero exit) propagates the real exit code instead of masquerading as ok. +/// Classify a `git commit` result. fn classify_commit_outcome(success: bool, stdout: &str, exit_code: i32) -> CommitOutcome { if success { - // Extract commit hash from output like "[main abc1234] message" or - // "[main (root-commit) abc1234] message" (incl. localized variants). + // Extract commit hash from output let compact = stdout .lines() .next() From eafadcee0042411ab9d28339d865a626567a72b0 Mon Sep 17 00:00:00 2001 From: Adrien Eppling Date: Fri, 26 Jun 2026 11:32:51 +0200 Subject: [PATCH 46/50] fix(grep): run the invoked engine instead of substituting rg for grep - grep runs grep, rg runs rg: drop the substitution, forced --no-ignore-vcs, and BRE-to-rg translation - add `rtk rg` command (native ripgrep, sharing the same output filter) - split rewrite rule: grep to rtk grep, rg to rtk rg - record the agent's real command in tracking (was synthesized as "grep -rn") - emit nothing on a clean no-match (never-worse parity with the shared guard) - rename grep_cmd.rs to search.rs, now hosting both engines - cover engine faithfulness, ignore semantics, and rg savings with issue-referenced tests - benchmark the grep and rg paths --- scripts/benchmark.sh | 17 +- src/cmds/system/README.md | 2 +- src/cmds/system/{grep_cmd.rs => search.rs} | 567 ++++++++------------- src/discover/registry.rs | 2 +- src/discover/rules.rs | 13 +- src/main.rs | 22 +- tests/grep_compress_test.rs | 9 +- tests/guard_integration_test.rs | 4 +- tests/search_faithful_test.rs | 202 ++++++++ 9 files changed, 476 insertions(+), 362 deletions(-) rename src/cmds/system/{grep_cmd.rs => search.rs} (75%) create mode 100644 tests/search_faithful_test.rs diff --git a/scripts/benchmark.sh b/scripts/benchmark.sh index 4eecf4f177..18f3d437c9 100755 --- a/scripts/benchmark.sh +++ b/scripts/benchmark.sh @@ -207,10 +207,19 @@ bench "git show" "git show HEAD --stat 2>/dev/null || true" "$RTK git show HEAD # grep # =================== section "grep" -bench "grep fn" "grep -rn 'fn ' src/ || true" "$RTK grep 'fn ' src/" -bench "grep struct" "grep -rn 'struct ' src/ || true" "$RTK grep 'struct ' src/" -bench "grep -l 40" "grep -rn 'fn ' src/ || true" "$RTK grep 'fn ' src/ -l 40" -bench "grep -c" "grep -ron 'fn ' src/ || true" "$RTK grep 'fn ' src/ -c" +bench "grep fn" "grep -rn 'fn ' src/ || true" "$RTK grep -rn 'fn ' src/" +bench "grep struct" "grep -rn 'struct ' src/ || true" "$RTK grep -rn 'struct ' src/" +bench "grep -l 40" "grep -rn 'fn ' src/ || true" "$RTK grep -rn 'fn ' src/ -l 40" +bench "grep -c" "grep -ron 'fn ' src/ || true" "$RTK grep -rc 'fn ' src/" + +# =================== +# rg (native ripgrep, recursive by default, same output filter) +# =================== +section "rg" +bench "rg fn" "rg -n 'fn ' src/ || true" "$RTK rg 'fn ' src/" +bench "rg struct" "rg -n 'struct ' src/ || true" "$RTK rg 'struct ' src/" +bench "rg -l files" "rg -l 'fn ' src/ || true" "$RTK rg -l 'fn ' src/" +bench "rg -c count" "rg -c 'fn ' src/ || true" "$RTK rg -c 'fn ' src/" # =================== # json diff --git a/src/cmds/system/README.md b/src/cmds/system/README.md index 55de289127..ae475befbd 100644 --- a/src/cmds/system/README.md +++ b/src/cmds/system/README.md @@ -5,7 +5,7 @@ ## Specifics - `read.rs` uses `core/filter` for language-aware code stripping (FilterLevel: none/minimal/aggressive) -- `grep_cmd.rs` reads `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. +- `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) diff --git a/src/cmds/system/grep_cmd.rs b/src/cmds/system/search.rs similarity index 75% rename from src/cmds/system/grep_cmd.rs rename to src/cmds/system/search.rs index 2797349a41..8b087d44ff 100644 --- a/src/cmds/system/grep_cmd.rs +++ b/src/cmds/system/search.rs @@ -1,4 +1,10 @@ -//! Filters grep output by grouping matches by file. +//! Shared search-output filter for `rtk grep` and `rtk rg`. +//! +//! Runs the agent's exact engine (grep or rg) — never substituting one for the +//! other — and compresses its output by grouping matches by file, capping, and +//! teeing overflow. The engine differs only in which binary and parse flags are +//! used (see `Engine`); the compression is identical because both emit the same +//! `file:line:content` shape. use crate::core::stream::{exec_capture, CaptureResult}; use crate::core::tracking; @@ -71,12 +77,10 @@ enum ClusterResult { /// Parse the content of a short flag cluster (everything after the leading `-`). /// -/// Scans left-to-right: strips `r`/`R`, accumulates boolean flag letters, and +/// Scans left-to-right, accumulating boolean flag letters — including `r`/`R`, +/// which pass through to grep (recursion is the agent's choice, not RTK's) — and /// stops at the first value-taking flag (from `VALUE_FLAGS_SHORT` or `e`). -/// Everything after that flag char in the cluster is its inline value and is -/// returned verbatim — no `r`/`R` stripping is applied to it. -/// -/// This is the only place in the codebase that touches cluster bytes. +/// Everything after that flag char is its inline value, returned verbatim. fn parse_cluster(rest: &str) -> ClusterResult { let bytes = rest.as_bytes(); let mut raw_prefix = String::new(); @@ -85,11 +89,10 @@ fn parse_cluster(rest: &str) -> ClusterResult { let ch = bytes[j]; let is_e = ch == b'e'; if is_e || VALUE_FLAGS_SHORT.contains(&ch) { - let prefix = strip_r(&raw_prefix); - // Inline value = bytes after this char; returned verbatim (no stripping). let inline = std::str::from_utf8(&bytes[j + 1..]) .unwrap_or("") .to_string(); + let prefix = (!raw_prefix.is_empty()).then_some(raw_prefix); return ClusterResult::ValueTaking { prefix, flag: ch as char, @@ -99,96 +102,29 @@ fn parse_cluster(rest: &str) -> ClusterResult { raw_prefix.push(ch as char); j += 1; } - ClusterResult::Boolean(strip_r(&raw_prefix)) + ClusterResult::Boolean((!raw_prefix.is_empty()).then_some(raw_prefix)) } -/// Strip `r`/`R` from a string of flag letters. -/// Returns `None` when nothing remains after stripping. -/// -/// Only called on accumulated flag letters (never on inline values). -/// `strip_r("carrot")` → `Some("caot")` — this shows exactly why it must not -/// touch value bytes; that corruption was the original `-ecarrot` bug. -fn strip_r(flag_letters: &str) -> Option { - let s: String = flag_letters +/// Unique, descriptive tee slug for a file's overflow matches. `idx` disambiguates +/// files within one grep; the tee filename's epoch handles separate runs. +fn grep_slug(idx: usize, path: &str) -> String { + let cleaned: String = path .chars() - .filter(|&c| c != 'r' && c != 'R') + .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) .collect(); - if s.is_empty() { - None - } else { - Some(s) - } + let tail = &cleaned[cleaned.len().saturating_sub(32)..]; + format!("grep_{}_{}", idx, tail) } -/// Drop `--recursive` (grep-ism); pass all other long flags through unchanged. -fn strip_recursive(arg: &str) -> Option { - match arg { - "--recursive" => None, - _ => Some(arg.to_string()), +/// Format a file's matches as `pathlinecontent`. Tee blocks use the +/// real (un-compacted) `path` so recovered lines stay openable. +fn match_block(path: &str, entries: &[(usize, bool, String)]) -> String { + let mut s = String::new(); + for (line_num, is_match, content) in entries { + let sep = if *is_match { ':' } else { '-' }; + s.push_str(&format!("{}{}{}{}{}\n", path, sep, line_num, sep, content)); } -} - -/// Ripgrep-only flags the grep fallback drops so grep doesn't abort (issue #2167). -const RG_ONLY_LONG: &[&str] = &[ - "--glob", - "--iglob", - "--type", - "--type-not", - "--type-add", - "--type-clear", - "--hidden", - "--no-ignore", - "--pcre2", - "--json", - "--stats", - "--sort", - "--sortr", - "--engine", - "--mmap", - "--no-mmap", - "--trim", - "--one-file-system", - "--max-columns", - "--max-depth", - "--max-filesize", - "--path-separator", - "--field-context-separator", - "--field-match-separator", - "--pre", - "--pre-glob", -]; - -fn strip_rg_only>(extra_args: &[T]) -> Vec<&str> { - let mut out = Vec::new(); - let mut skip_next = false; - for arg in extra_args { - let a = arg.as_ref(); - if skip_next { - skip_next = false; - continue; - } - let name = a.split('=').next().unwrap_or(a); - let rg_only = RG_ONLY_LONG.contains(&name) || a == "-g" || a == "-T"; - if rg_only { - let takes_value = a == "-g" || a == "-T" || VALUE_FLAGS_LONG.contains(&name); - if takes_value && !a.contains('=') { - skip_next = true; - } - continue; - } - out.push(a); - } - out -} - -fn has_shape_flag>(extra_args: &[T]) -> bool { - extra_args.iter().any(|arg| { - let name = arg.as_ref().split('=').next().unwrap_or(""); - matches!( - name, - "--column" | "--vimgrep" | "-b" | "--byte-offset" | "--null-data" - ) - }) + s } /// Extracts `(patterns, paths, flags)` from the raw trailing args. @@ -245,10 +181,7 @@ fn extract_pattern_path>(args: &[T]) -> (Vec, Vec, } continue; } - // Drop --recursive; pass everything else through. - if let Some(cleaned) = strip_recursive(arg) { - flags.push(cleaned); - } + flags.push(arg.to_string()); i += 1; continue; } @@ -324,126 +257,119 @@ fn unparsed_signal(stdout: &str) -> usize { .count() } -/// Run rg; fall back to system grep when rg can't run or rejects the invocation -/// (exit 2, no output) so any grep-ism (e.g. --include) still works (#2543). -/// #2167: if grep also aborts on a flag, retry it bare. -fn grep_capture>( - rg_base: &[&str], - grep_base: &[&str], - file_type: Option<&str>, +/// Run real grep so matches and the savings baseline match the agent's command; +/// rg is the fallback when grep is absent, rejects a flag, or `--type` is used. +/// The search engine the agent actually invoked. RTK runs this binary verbatim +/// and never substitutes one for the other. +#[derive(Clone, Copy)] +pub enum Engine { + Grep, + Rg, +} + +impl Engine { + fn bin(self) -> &'static str { + match self { + Engine::Grep => "grep", + Engine::Rg => "rg", + } + } + + pub fn label(self) -> &'static str { + self.bin() + } + + /// Parse aids only: line numbers + always-shown filename, NUL-separated so the + /// regroup is unambiguous (#1436), binary noise skipped. None of these change + /// which lines match. + fn parse_flags(self) -> &'static [&'static str] { + match self { + Engine::Grep => &["-n", "-H", "-I", "--null"], + Engine::Rg => &["-n", "--with-filename", "--null"], + } + } +} + +/// Runs the agent's exact engine + flags for the grouping path, appending only the +/// parse aids (see `Engine::parse_flags`). +fn engine_capture>( + engine: Engine, extra_args: &[T], patterns: &[String], paths: &[String], ) -> Result { - let mut rg_cmd = resolved_command("rg"); - rg_cmd.args(rg_base); - if let Some(ft) = file_type { - rg_cmd.arg("--type").arg(ft); + let mut cmd = resolved_command(engine.bin()); + cmd.args(engine.parse_flags()); + for a in extra_args { + cmd.arg(a.as_ref()); } - rg_cmd.args(extra_args.iter().map(|a| a.as_ref())); for p in patterns { - rg_cmd.args(["-e", &p.replace(r"\|", "|")]); - } - rg_cmd.arg("--"); - rg_cmd.args(paths); - - match exec_capture(&mut rg_cmd) { - Ok(r) if !(r.exit_code == 2 && r.stdout.is_empty()) => Ok(r), - _ => { - // --null not -Z: BSD grep -Z is --decompress (#2310); strip_rg_only (#2167). - let stripped = strip_rg_only(extra_args); - let result = run_grep_fallback(grep_base, file_type, &stripped, patterns, paths)?; - if result.exit_code == 2 && result.stdout.is_empty() && result.stderr.contains("option") { - // grep aborted on an rg-only flag that strip_rg_only missed; retry bare (#2167). - run_grep_fallback(grep_base, file_type, &[], patterns, paths) - } else { - Ok(result) - } - } + cmd.args(["-e", p]); } + cmd.arg("--"); + cmd.args(paths); + exec_capture(&mut cmd).context("search failed") } -fn run_grep_fallback( - grep_base: &[&str], - file_type: Option<&str>, - extra_args: &[&str], +/// Runs the engine with no injected flags at all (already-minimal output forms). +fn engine_raw>( + engine: Engine, + extra_args: &[T], patterns: &[String], paths: &[String], ) -> Result { - let mut grep_cmd = resolved_command("grep"); - grep_cmd.args(grep_base); - // file_type cannot be translated to grep syntax; silently skip. - let _ = file_type; - grep_cmd.args(extra_args); + let mut cmd = resolved_command(engine.bin()); + for a in extra_args { + cmd.arg(a.as_ref()); + } for p in patterns { - grep_cmd.args(["-e", p]); + cmd.args(["-e", p]); } - grep_cmd.arg("--"); - grep_cmd.args(paths); - exec_capture(&mut grep_cmd).context("grep/rg failed") + cmd.arg("--"); + cmd.args(paths); + exec_capture(&mut cmd).context("search failed") } #[allow(clippy::too_many_arguments)] fn passthrough>( timer: &tracking::TimedExecution, - base: &[&str], - grep_base: &[&str], - file_type: Option<&str>, + engine: Engine, extra_args: &[T], patterns: &[String], paths: &[String], - pattern_display: &str, - path_display: &str, + real_cmd: &str, ) -> Result { - let result = grep_capture(base, grep_base, file_type, extra_args, patterns, paths) - .context("grep/rg failed")?; + let result = engine_raw(engine, extra_args, patterns, paths)?; print!("{}", strip_ansi(&result.stdout)); if !result.stderr.is_empty() { eprint!("{}", result.stderr.trim()); } - let args_display = if extra_args.is_empty() { - format!("'{}' {}", pattern_display, path_display) - } else { - let joined: Vec<&str> = extra_args.iter().map(|a| a.as_ref()).collect(); - format!("{} '{}' {}", joined.join(" "), pattern_display, path_display) - }; - - timer.track_passthrough( - &format!("grep {}", args_display), - &format!("rtk grep {} (passthrough)", args_display), - ); + timer.track_passthrough(real_cmd, &format!("rtk {} (passthrough)", real_cmd)); Ok(result.exit_code) } pub fn run( + engine: Engine, max_line_len: usize, max_results: usize, context_only: bool, - file_type: Option<&str>, args: &[String], verbose: u8, ) -> Result { let timer = tracking::TimedExecution::start(); - // --version / --help: pass through to rg without filtering. + // --version / --help: pass through to the engine without filtering. // Note: Clap strips `--` before populating trailing_var_arg, so both // `rtk grep --version` and `rtk grep -- --version` land here identically. if args .iter() .any(|a| a == "--version" || a == "--help" || a == "-h") { - let mut rg_cmd = resolved_command("rg"); - rg_cmd.args(args); - let result = exec_capture(&mut rg_cmd) - .or_else(|_| { - // rg unavailable: fall back to system grep. - let mut grep_cmd = resolved_command("grep"); - grep_cmd.args(args); - exec_capture(&mut grep_cmd) - }) - .context("grep/rg failed")?; + let mut cmd = resolved_command(engine.bin()); + cmd.args(args); + let result = exec_capture(&mut cmd).context("search failed")?; print!("{}", result.stdout); if !result.stderr.is_empty() { eprint!("{}", result.stderr); @@ -453,6 +379,8 @@ pub fn run( // Re-insert `--` when clap's trailing_var_arg consumed it let args = args_utils::restore_double_dash(args); + let real_cmd = format!("{} {}", engine.label(), args.join(" ")); + let rtk_label = format!("rtk {}", engine.label()); let (patterns, paths, extra_args) = extract_pattern_path(&args); @@ -478,31 +406,12 @@ pub fn run( eprintln!("grep: '{}' in {}", pattern_display, path_display); } - // format/shape flags: native passthrough, no -0 NUL leak (#2333). - if has_format_flag(&extra_args) || has_shape_flag(&extra_args) { - return passthrough( - &timer, - &["--no-heading", "--no-ignore-vcs"], - &["-r"], - file_type, - &extra_args, - &patterns, - &paths, - &pattern_display, - &path_display, - ); + // format/shape flags (-c/-l/-o/...): already-minimal native output, passthrough. + if has_format_flag(&extra_args) { + return passthrough(&timer, engine, &extra_args, &patterns, &paths, &real_cmd); } - // GROUP path: -0 NUL-disambiguates file:line for the reparse (#1436). - let result = grep_capture( - &["-nH0", "--no-heading", "--no-ignore-vcs"], - &["-rnH", "--null"], - file_type, - &extra_args, - &patterns, - &paths, - ) - .context("grep/rg failed")?; + let result = engine_capture(engine, &extra_args, &patterns, &paths)?; let exit_code = result.exit_code; let raw_output = result.stdout.clone(); @@ -512,39 +421,21 @@ pub fn run( if !result.stderr.trim().is_empty() { eprintln!("{}", result.stderr.trim()); } - let msg = format!("grep failed with exit code {}", exit_code); - timer.track( - &format!("grep -rn '{}' {}", pattern_display, path_display), - "rtk grep", - &raw_output, - &msg, - ); + let msg = format!("search failed with exit code {}", exit_code); + timer.track(&real_cmd, &rtk_label, &raw_output, &msg); eprintln!("{}", msg); return Ok(exit_code); } - timer.track( - &format!("grep -rn '{}' {}", pattern_display, path_display), - "rtk grep", - &raw_output, - "", - ); + // grep/rg print nothing on a clean no-match; a "0 matches" line would + // exceed the real command's output, so emit nothing (never-worse). + timer.track(&real_cmd, &rtk_label, &raw_output, ""); return Ok(exit_code); } // Safety net: unparseable shape → passthrough verbatim, never silently drop (#2333). let signal = unparsed_signal(&raw_output); if signal > 0 { - return passthrough( - &timer, - &["-nH", "--no-heading", "--no-ignore-vcs"], - &["-rnH"], - file_type, - &extra_args, - &patterns, - &paths, - &pattern_display, - &path_display, - ); + return passthrough(&timer, engine, &extra_args, &patterns, &paths, &real_cmd); } let context_re = if context_only { @@ -587,28 +478,51 @@ pub fn run( files.sort_by_key(|(f, _)| *f); let per_file = config::limits().grep_max_per_file; - for (file, entries) in files { + let mut skipped_files = 0; + let mut skipped_block = String::new(); + for (idx, (file, entries)) in files.into_iter().enumerate() { if shown >= max_results { - break; + skipped_files += 1; + skipped_block.push_str(&match_block(file, entries)); + continue; } let file_display = compact_path(file); + let mut file_shown = 0; for (line_num, is_match, content) in entries.iter().take(per_file) { if shown >= max_results { break; } - if *is_match { - rtk_output.push_str(&format!("{}:{}:{}\n", file_display, line_num, content)); - } else { - rtk_output.push_str(&format!("{}-{}-{}\n", file_display, line_num, content)); - } + let sep = if *is_match { ':' } else { '-' }; + rtk_output.push_str(&format!( + "{}{}{}{}{}\n", + file_display, sep, line_num, sep, content + )); shown += 1; + file_shown += 1; + } + + let remaining = entries.len() - file_shown; + if remaining == 0 { + continue; + } + // Tee the file's full matches (real path) so the tail hint recovers them + // openably, skipping the lines already shown. + let full_block = match_block(file, entries); + match crate::core::tee::force_tee_tail_hint(&full_block, &grep_slug(idx, file), file_shown + 1) + { + Some(hint) => { + rtk_output.push_str(&format!(" +{} more in {} — {}\n", remaining, file_display, hint)) + } + None => rtk_output.push_str(&format!(" +{} more in {}\n", remaining, file_display)), } } - let total_lines: usize = by_file.values().map(|v| v.len()).sum(); - if total_lines > shown { - rtk_output.push_str(&format!("[+{} more]\n", total_lines - shown)); + if skipped_files > 0 { + let hint = crate::core::tee::force_tee_tail_hint(&skipped_block, "grep_skipped", 1) + .map(|h| format!(" — {}", h)) + .unwrap_or_default(); + rtk_output.push_str(&format!("+{} more files{}\n", skipped_files, hint)); } // Never-worse: show plain `file:line:content` (NUL `-0` -> `:`) if grouping didn't shrink it. @@ -620,12 +534,7 @@ pub fn run( }; print!("{}", output); - timer.track( - &format!("grep -rn '{}' {}", pattern_display, path_display), - "rtk grep", - &raw_output, - &output, - ); + timer.track(&real_cmd, &rtk_label, &raw_output, &output); Ok(exit_code) } @@ -658,23 +567,37 @@ fn parse_match_line(line: &str) -> Option<(String, usize, bool, &str)> { } fn has_format_flag>(extra_args: &[T]) -> bool { + // Minimal/shape forms the agent already chose; short flags scanned per-letter + // so clusters like -rl/-rq route through, plus their long forms. + const LONG: &[&str] = &[ + "--count", + "--count-matches", + "--files-with-matches", + "--files-without-match", + "--only-matching", + "--quiet", + "--silent", + "--byte-offset", + "--column", + "--vimgrep", + "--null", + "--null-data", + "--json", + "--passthru", + "--files", + ]; extra_args.iter().any(|arg| { - matches!( - arg.as_ref(), - "-c" | "--count" - | "--count-matches" - | "-l" - | "--files-with-matches" - | "-L" - | "--files-without-match" - | "-o" - | "--only-matching" - | "-Z" - | "--null" - | "--json" - | "--passthru" - | "--files" - ) + let a = arg.as_ref(); + if a.starts_with("--") { + LONG.contains(&a.split('=').next().unwrap_or(a)) + } else if let Some(letters) = a.strip_prefix('-').filter(|s| !s.is_empty()) { + // -c count, -l/-L lists, -o only-matching, -q quiet, -b byte-offset, -Z/-z NUL + letters + .chars() + .any(|ch| matches!(ch, 'c' | 'l' | 'L' | 'o' | 'q' | 'b' | 'Z' | 'z')) + } else { + false + } }) } @@ -796,14 +719,6 @@ mod tests { assert!(!cleaned.is_empty()); } - // Fix: BRE \| alternation is translated to PCRE | for rg - #[test] - fn test_bre_alternation_translated() { - let pattern = r"fn foo\|pub.*bar"; - let rg_pattern = pattern.replace(r"\|", "|"); - assert_eq!(rg_pattern, "fn foo|pub.*bar"); - } - // --- parse_cluster --- fn vt(prefix: Option<&str>, flag: char, inline: &str) -> ClusterResult { @@ -816,17 +731,26 @@ mod tests { #[test] fn test_parse_cluster_boolean_only() { - // Pure boolean clusters: r/R stripped, remainder emitted - assert_eq!(parse_cluster("r"), ClusterResult::Boolean(None)); - assert_eq!(parse_cluster("R"), ClusterResult::Boolean(None)); - assert_eq!(parse_cluster("rR"), ClusterResult::Boolean(None)); + // Pure boolean clusters: r/R kept and passed through to grep + assert_eq!( + parse_cluster("r"), + ClusterResult::Boolean(Some("r".to_string())) + ); + assert_eq!( + parse_cluster("R"), + ClusterResult::Boolean(Some("R".to_string())) + ); + assert_eq!( + parse_cluster("rR"), + ClusterResult::Boolean(Some("rR".to_string())) + ); assert_eq!( parse_cluster("rn"), - ClusterResult::Boolean(Some("n".to_string())) + ClusterResult::Boolean(Some("rn".to_string())) ); assert_eq!( parse_cluster("Rni"), - ClusterResult::Boolean(Some("ni".to_string())) + ClusterResult::Boolean(Some("Rni".to_string())) ); assert_eq!( parse_cluster("n"), @@ -872,14 +796,14 @@ mod tests { #[test] fn test_parse_cluster_rne() { - // -rne: r stripped, n in boolean prefix, e is value-taking (empty inline) - assert_eq!(parse_cluster("rne"), vt(Some("n"), 'e', "")); + // r/R pass through; e is value-taking (empty inline) + assert_eq!(parse_cluster("rne"), vt(Some("rn"), 'e', "")); } #[test] fn test_parse_cluster_r_a() { - // -rA: r stripped, A is value-taking (empty inline → consume next token) - assert_eq!(parse_cluster("rA"), vt(None, 'A', "")); + // r passes through in the prefix; A is value-taking + assert_eq!(parse_cluster("rA"), vt(Some("r"), 'A', "")); } #[test] @@ -906,30 +830,6 @@ mod tests { assert_eq!(parse_cluster("M120"), vt(None, 'M', "120")); } - // --- strip_r --- - - #[test] - fn test_strip_r() { - assert_eq!(strip_r("r"), None); - assert_eq!(strip_r("R"), None); - assert_eq!(strip_r("rR"), None); - assert_eq!(strip_r(""), None); - assert_eq!(strip_r("rn"), Some("n".to_string())); - assert_eq!(strip_r("Rni"), Some("ni".to_string())); - assert_eq!(strip_r("i"), Some("i".to_string())); - // Shows why it must only be called on flag letters, not value bytes: - assert_eq!(strip_r("carrot"), Some("caot".to_string())); - } - - // --- strip_recursive --- - - #[test] - fn test_strip_recursive() { - assert_eq!(strip_recursive("--recursive"), None); - assert_eq!(strip_recursive("--glob"), Some("--glob".to_string())); - assert_eq!(strip_recursive("--type"), Some("--type".to_string())); - } - // --- extract_pattern_path --- #[test] @@ -958,30 +858,30 @@ mod tests { } #[test] - fn test_extract_cluster_strip_r() { - // -rn: r stripped, n forwarded (not leaked to rg as --replace value) + fn test_extract_cluster_keeps_r() { + // -rn: r kept, passed straight to grep let (patterns, paths, flags) = extract_pattern_path(&["-rn", "foo", "src"]); assert_eq!(patterns, vec!["foo"]); assert_eq!(paths, vec!["src"]); - assert_eq!(flags, vec!["-n"]); + assert_eq!(flags, vec!["-rn"]); } #[test] fn test_extract_cluster_ending_in_e() { - // -rne PATTERN: r stripped, n in prefix, e consumes PATTERN as pattern + // -rne PATTERN: rn kept, e consumes PATTERN as the pattern let (patterns, paths, flags) = extract_pattern_path(&["-rne", "PATTERN", "src"]); assert_eq!(patterns, vec!["PATTERN"]); assert_eq!(paths, vec!["src"]); - assert_eq!(flags, vec!["-n"]); + assert_eq!(flags, vec!["-rn"]); } #[test] fn test_extract_cluster_ending_in_value_flag() { - // -rA 2: r stripped, A consumes 2 as context value + // -rA 2: r kept as its own flag, A consumes 2 as context value let (patterns, paths, flags) = extract_pattern_path(&["-rA", "2", "foo", "src"]); assert_eq!(patterns, vec!["foo"]); assert_eq!(paths, vec!["src"]); - assert_eq!(flags, vec!["-A", "2"]); + assert_eq!(flags, vec!["-r", "-A", "2"]); } #[test] @@ -1226,7 +1126,7 @@ mod tests { fn test_grep_overflow_uses_uncapped_total() { // Confirm the grep overflow invariant: matches vec is never capped before overflow calc. // If total_matches > per_file, overflow = total_matches - per_file (not capped). - // This documents that grep_cmd.rs avoids the diff_cmd bug (cap at N then compute N-10). + // This documents that the search filter avoids the diff_cmd bug (cap at N then compute N-10). let per_file = config::limits().grep_max_per_file; let total_matches = per_file + 42; let overflow = total_matches - per_file; @@ -1279,43 +1179,42 @@ mod tests { } #[test] - fn test_strip_rg_only_drops_glob_and_value() { - assert_eq!(strip_rg_only(&["--glob", "*.rs", "-i"]), vec!["-i"]); - assert_eq!(strip_rg_only(&["-g", "*.rs", "-i"]), vec!["-i"]); - } - - #[test] - fn test_strip_rg_only_drops_inline_glob() { - assert_eq!(strip_rg_only(&["--glob=*.rs", "-i"]), vec!["-i"]); - } - - #[test] - fn test_strip_rg_only_drops_bool_flags() { - assert_eq!( - strip_rg_only(&["--hidden", "--pcre2", "--json", "-n"]), - vec!["-n"] - ); + fn test_format_flag_detects_clusters() { + // clustered minimal forms must route to passthrough, not GROUP + assert!(has_format_flag(&["-rl"])); + assert!(has_format_flag(&["-rc"])); + assert!(has_format_flag(&["-rq"])); + assert!(has_format_flag(&["-rln"])); + assert!(has_format_flag(&["-cr"])); } #[test] - fn test_strip_rg_only_drops_type_and_value() { - assert_eq!(strip_rg_only(&["--type", "rust", "-w"]), vec!["-w"]); - assert_eq!(strip_rg_only(&["-T", "rust", "-w"]), vec!["-w"]); + fn test_format_flag_detects_quiet_and_shape() { + assert!(has_format_flag(&["-q"])); + assert!(has_format_flag(&["--quiet"])); + assert!(has_format_flag(&["--silent"])); + assert!(has_format_flag(&["-b"])); + assert!(has_format_flag(&["--byte-offset"])); + assert!(has_format_flag(&["--column"])); + assert!(has_format_flag(&["--vimgrep"])); + assert!(has_format_flag(&["-z"])); + assert!(has_format_flag(&["--null-data"])); } #[test] - fn test_strip_rg_only_keeps_grep_compatible() { - assert_eq!( - strip_rg_only(&["-i", "-w", "-A", "3", "-v"]), - vec!["-i", "-w", "-A", "3", "-v"] - ); + fn test_format_flag_compresses_default_and_context() { + // compressible forms must NOT passthrough + assert!(!has_format_flag(&["-rn"])); + assert!(!has_format_flag(&["-A", "3"])); + assert!(!has_format_flag(&["-v"])); + assert!(!has_format_flag(&["-rin"])); } - // Verify line numbers are always enabled in rg invocation (grep_cmd.rs:24). + // Verify line numbers are always enabled in the engine invocation (parse_flags). // The -n/--line-numbers clap flag in main.rs is a no-op accepted for compat. #[test] fn test_rg_always_has_line_numbers() { - // grep_cmd::run() always passes "-n" to rg (line 24). + // engine_capture always passes "-n" to the engine via parse_flags(). // This test documents that -n is built-in, so the clap flag is safe to ignore. let mut cmd = resolved_command("rg"); cmd.args(["-n", "--no-heading", "NONEXISTENT_PATTERN_12345", "."]); @@ -1329,7 +1228,7 @@ mod tests { // If rg is not installed, skip gracefully (test still passes) } - // --- issue #1436: parse_match_line robustness --- + // --- issues #1436 / #1613: parse_match_line robustness (single-file colon misparse) --- // Input shape is `file\0line[:-]content` (rg --null / grep -Z). #[test] @@ -1427,26 +1326,6 @@ mod tests { assert_eq!(content, "after1"); } - #[test] - fn test_rg_no_ignore_vcs_flag_accepted() { - // Verify rg accepts --no-ignore-vcs (used to match grep -r behavior for .gitignore) - let mut cmd = resolved_command("rg"); - cmd.args([ - "-n", - "--no-heading", - "--no-ignore-vcs", - "NONEXISTENT_PATTERN_12345", - ".", - ]); - if let Ok(output) = cmd.output() { - assert!( - output.status.code() == Some(1) || output.status.success(), - "rg --no-ignore-vcs should be accepted" - ); - } - // If rg is not installed, skip gracefully (test still passes) - } - // --- unparsed_signal --- #[test] diff --git a/src/discover/registry.rs b/src/discover/registry.rs index fc18b6be07..b459cb02b0 100644 --- a/src/discover/registry.rs +++ b/src/discover/registry.rs @@ -1425,7 +1425,7 @@ mod tests { fn test_rewrite_rg_pattern() { assert_eq!( rewrite_command_no_prefixes("rg \"fn main\"", &[]), - Some("rtk grep \"fn main\"".into()) + Some("rtk rg \"fn main\"".into()) ); } diff --git a/src/discover/rules.rs b/src/discover/rules.rs index e9bf1b1f31..cb66e422f5 100644 --- a/src/discover/rules.rs +++ b/src/discover/rules.rs @@ -89,9 +89,18 @@ pub const RULES: &[RtkRule] = &[ subcmd_status: &[], }, RtkRule { - pattern: r"^(rg|grep)\s+", + pattern: r"^grep\s+", rtk_cmd: "rtk grep", - rewrite_prefixes: &["rg", "grep"], + rewrite_prefixes: &["grep"], + category: "Files", + savings_pct: 75.0, + subcmd_savings: &[], + subcmd_status: &[], + }, + RtkRule { + pattern: r"^rg\s+", + rtk_cmd: "rtk rg", + rewrite_prefixes: &["rg"], category: "Files", savings_pct: 75.0, subcmd_savings: &[], diff --git a/src/main.rs b/src/main.rs index d919afe8e3..d13bb448cf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,8 +20,8 @@ use cmds::python::{mypy_cmd, pip_cmd, pytest_cmd, ruff_cmd}; use cmds::ruby::{rake_cmd, rspec_cmd, rubocop_cmd}; use cmds::rust::{cargo_cmd, runner}; use cmds::system::{ - deps, env_cmd, find_cmd, format_cmd, grep_cmd, json_cmd, local_llm, log_cmd, ls, pipe_cmd, - read, summary, tree, wc_cmd, + deps, env_cmd, find_cmd, format_cmd, json_cmd, local_llm, log_cmd, ls, pipe_cmd, read, search, + summary, tree, wc_cmd, }; use anyhow::{Context, Result}; @@ -322,6 +322,13 @@ enum Commands { extra_args: Vec, }, + /// Compact ripgrep - runs rg natively, same output filter as grep + Rg { + /// Pattern, path, and any rg flags (e.g. -v, -i, -t rust, --glob) + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + extra_args: Vec, + }, + /// Initialize rtk instructions for assistant CLI usage Init { /// Add to global assistant config directory instead of local project file @@ -1857,16 +1864,19 @@ fn run_cli() -> Result { max_len, max, context_only, - file_type, + file_type: _, extra_args, - } => grep_cmd::run( + } => search::run( + search::Engine::Grep, max_len, max, context_only, - file_type.as_deref(), &extra_args, cli.verbose, )?, + Commands::Rg { extra_args } => { + search::run(search::Engine::Rg, 80, 200, false, &extra_args, cli.verbose)? + } Commands::Init { global, @@ -2569,6 +2579,7 @@ fn is_operational_command(cmd: &Commands) -> bool { | Commands::Oc { .. } | Commands::Summary { .. } | Commands::Grep { .. } + | Commands::Rg { .. } | Commands::Wget { .. } | Commands::Vitest { .. } | Commands::Prisma { .. } @@ -2921,6 +2932,7 @@ mod tests { "ls", "tree", "read", + "rg", "git", "gh", "glab", diff --git a/tests/grep_compress_test.rs b/tests/grep_compress_test.rs index bd36df3e7d..eb563bbea8 100644 --- a/tests/grep_compress_test.rs +++ b/tests/grep_compress_test.rs @@ -120,7 +120,7 @@ fn no_line_number_flag_produces_output_not_zero_matches() { } let (_dir, path) = write_temp("hello world\n"); let out = rtk() - .args(["grep", "-N", "hello", path.to_str().unwrap()]) + .args(["rg", "-N", "hello", path.to_str().unwrap()]) .output() .expect("rtk grep"); let stdout = String::from_utf8_lossy(&out.stdout); @@ -164,7 +164,7 @@ fn heading_flag_produces_output_not_zero_matches() { } let (_dir, path) = write_temp("hello world\n"); let out = rtk() - .args(["grep", "--heading", "hello", path.to_str().unwrap()]) + .args(["rg", "--heading", "hello", path.to_str().unwrap()]) .output() .expect("rtk grep"); let stdout = String::from_utf8_lossy(&out.stdout); @@ -186,7 +186,7 @@ fn pretty_flag_produces_output_not_zero_matches() { } let (_dir, path) = write_temp("hello world\n"); let out = rtk() - .args(["grep", "-p", "hello", path.to_str().unwrap()]) + .args(["rg", "-p", "hello", path.to_str().unwrap()]) .output() .expect("rtk grep"); let stdout = String::from_utf8_lossy(&out.stdout); @@ -210,7 +210,7 @@ fn column_flag_output_has_no_nul() { } let (_dir, path) = write_temp("hello world\n"); let out = rtk() - .args(["grep", "--column", "hello", path.to_str().unwrap()]) + .args(["rg", "--column", "hello", path.to_str().unwrap()]) .output() .expect("rtk grep"); let stdout = String::from_utf8_lossy(&out.stdout); @@ -231,6 +231,7 @@ fn count_tokens(s: &str) -> usize { s.split_whitespace().count() } +// Covers #545: grep savings are measured against the real grep output. #[test] fn bulky_grep_yields_token_savings() { if !rg_available() { diff --git a/tests/guard_integration_test.rs b/tests/guard_integration_test.rs index 494ac05383..b776918003 100644 --- a/tests/guard_integration_test.rs +++ b/tests/guard_integration_test.rs @@ -103,7 +103,9 @@ fn grep_no_match_emits_empty_not_a_message() { } let dir = tempfile::tempdir().expect("tempdir"); std::fs::write(dir.path().join("a.txt"), "hello world\n").expect("write"); - let (out, code) = rtk_in_dir(dir.path(), &["grep", "zzz_no_match_xyz", "."]); + // Faithful grep needs -r to descend a directory; we no longer force recursion + // by routing through rg (the engine-faithful contract). + let (out, code) = rtk_in_dir(dir.path(), &["grep", "-r", "zzz_no_match_xyz", "."]); assert!( out.trim().is_empty(), "no-match grep must emit empty, not a '0 matches' line: {out:?}" diff --git a/tests/search_faithful_test.rs b/tests/search_faithful_test.rs new file mode 100644 index 0000000000..537311979c --- /dev/null +++ b/tests/search_faithful_test.rs @@ -0,0 +1,202 @@ +#![cfg(unix)] +//! Engine-faithfulness contract: `rtk grep` runs grep and `rtk rg` runs rg, each +//! with its own regex dialect and ignore semantics. rtk filters output noise only; +//! it never substitutes one engine for the other (the bug this PR removes). + +use std::process::Command; + +fn rtk() -> Command { + Command::new(env!("CARGO_BIN_EXE_rtk")) +} + +fn rg_available() -> bool { + Command::new("rg") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +fn write_temp(content: &str) -> (tempfile::TempDir, std::path::PathBuf) { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("test.txt"); + std::fs::write(&path, content).expect("write"); + (dir, path) +} + +fn count_tokens(s: &str) -> usize { + s.split_whitespace().count() +} + +// --- regex dialect: the single most important regression guard --- + +// Covers #2253 (matches silently rewritten when grep was routed through rg), +// #2301 (rg routed to BSD grep, wrong dialect) and #545 (savings can't be faked +// against a foreign engine's output). +#[test] +fn grep_and_rg_use_their_own_regex_dialect() { + if !rg_available() { + return; + } + // "alpha" holds "al" and "gamma" holds "ga", but neither line ever contains + // the literal string "al|ga". + let (_dir, path) = write_temp("alpha\ngamma\n"); + let p = path.to_str().unwrap(); + + // grep BRE: a bare `|` is a literal pipe, so "al|ga" matches nothing -> exit 1. + let g = rtk().args(["grep", "al|ga", p]).output().expect("rtk grep"); + assert_eq!( + g.status.code(), + Some(1), + "grep must treat | as a literal pipe (0 matches), proving no rg substitution:\n{}", + String::from_utf8_lossy(&g.stdout) + ); + + // rg: `|` is alternation, so "al|ga" matches both lines -> exit 0. + let r = rtk().args(["rg", "al|ga", p]).output().expect("rtk rg"); + assert_eq!( + r.status.code(), + Some(0), + "rg must treat | as alternation (matches), proving it runs rg, not grep" + ); + let r_out = String::from_utf8_lossy(&r.stdout); + assert!( + r_out.contains("alpha") && r_out.contains("gamma"), + "rg alternation must match both lines:\n{r_out}" + ); +} + +// --- ignore semantics: rg stays lean, grep -r descends (the node_modules flood fix) --- + +// Covers #2606 (rg must keep .gitignore/binary skipping) and #2064 (forced +// --no-ignore-vcs made rg read node_modules and dump minified files). +#[test] +fn rg_honors_ignore_files_grep_does_not() { + if !rg_available() { + return; + } + let dir = tempfile::tempdir().expect("tempdir"); + // `.ignore` is rg-native and applies without a surrounding git repo. + std::fs::write(dir.path().join(".ignore"), "skip.txt\n").expect("write"); + std::fs::write(dir.path().join("skip.txt"), "NEEDLE here\n").expect("write"); + std::fs::write(dir.path().join("keep.txt"), "NEEDLE here\n").expect("write"); + let path = dir.path().to_str().unwrap(); + + // rg respects the ignore file: skip.txt must not appear (no forced --no-ignore-vcs). + let r = rtk().args(["rg", "NEEDLE", path]).output().expect("rtk rg"); + let r_out = String::from_utf8_lossy(&r.stdout); + assert!( + r_out.contains("keep.txt"), + "rg must match the non-ignored file:\n{r_out}" + ); + assert!( + !r_out.contains("skip.txt"), + "rg must honor .ignore and skip skip.txt (no forced --no-ignore-vcs flood):\n{r_out}" + ); + + // grep knows nothing of ignore files: -r descends and matches both. + let g = rtk() + .args(["grep", "-rn", "NEEDLE", path]) + .output() + .expect("rtk grep"); + let g_out = String::from_utf8_lossy(&g.stdout); + assert!( + g_out.contains("keep.txt") && g_out.contains("skip.txt"), + "grep -r must descend into every file, ignoring .ignore:\n{g_out}" + ); +} + +// --- token savings: rg path compresses as much as the grep path --- + +// Covers #545: rg savings are measured against the real rg output, not a +// substituted engine's inflated result. +#[test] +fn bulky_rg_yields_token_savings() { + if !rg_available() { + return; + } + let filler: String = (0..50).map(|i| format!("word{i} ")).collect(); + let mut content = String::new(); + for i in 0..20 { + content.push_str(&format!("MATCH line {i} {filler}\n")); + } + let (_dir, path) = write_temp(&content); + + let raw = Command::new("rg") + .args(["-nH", "MATCH", path.to_str().unwrap()]) + .output() + .expect("rg"); + let raw_tokens = count_tokens(&String::from_utf8_lossy(&raw.stdout)); + + let out = rtk() + .args(["rg", "MATCH", path.to_str().unwrap()]) + .output() + .expect("rtk rg"); + let rtk_tokens = count_tokens(&String::from_utf8_lossy(&out.stdout)); + + let savings = 100.0 - (rtk_tokens as f64 / raw_tokens as f64 * 100.0); + assert!( + savings >= 50.0, + "expected >=50% rg token savings, got {savings:.1}% (raw={raw_tokens}, rtk={rtk_tokens})" + ); +} + +// --- engine-specific flags reach their own engine --- + +// `-E` selects grep's extended regex; the flag must reach grep, never be swallowed +// as ripgrep's `--encoding` (#2253) or routed to the wrong engine (#2167). +#[test] +fn grep_dash_e_selects_extended_regex() { + if !rg_available() { + return; + } + let (_dir, path) = write_temp("alpha\ngamma\n"); + let p = path.to_str().unwrap(); + + // Plain BRE: bare `|` is literal -> no match, exit 1. + let bre = rtk().args(["grep", "al|ga", p]).output().expect("rtk grep"); + assert_eq!(bre.status.code(), Some(1), "BRE | must stay literal"); + + // -E (ERE): `|` is alternation -> both lines match, exit 0. + let ere = rtk() + .args(["grep", "-E", "al|ga", p]) + .output() + .expect("rtk grep -E"); + assert_eq!( + ere.status.code(), + Some(0), + "grep -E must enable ERE alternation, proving -E reaches grep and is not read as rg --encoding" + ); + let out = String::from_utf8_lossy(&ere.stdout); + assert!( + out.contains("alpha") && out.contains("gamma"), + "grep -E alternation must match both lines:\n{out}" + ); +} + +// rg's `-g/--glob` filters the file set; it must reach rg with correct ordering, +// not be mangled by a grep that has no such flag (#1824, #2167). +#[test] +fn rg_glob_flag_filters_the_file_set() { + if !rg_available() { + return; + } + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write(dir.path().join("a.rs"), "NEEDLE in rust\n").expect("write"); + std::fs::write(dir.path().join("b.txt"), "NEEDLE in text\n").expect("write"); + let path = dir.path().to_str().unwrap(); + + let out = rtk() + .args(["rg", "-g", "*.rs", "NEEDLE", path]) + .output() + .expect("rtk rg -g"); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("a.rs"), + "rg -g '*.rs' must match the rust file:\n{stdout}" + ); + assert!( + !stdout.contains("b.txt"), + "rg -g '*.rs' must exclude files outside the glob:\n{stdout}" + ); +} From 37ee6cf6fa7ecb1b511aafd0b111b68ba1d5c945 Mon Sep 17 00:00:00 2001 From: Adrien Eppling Date: Fri, 26 Jun 2026 17:25:01 +0200 Subject: [PATCH 47/50] fix(grep): match real grep output and read piped stdin - line number always (the openable position); filename only when grep/rg prints one (multi-file, dir, -r, -H) - single file and stdin equal `grep -n` byte-for-byte: no synthetic filename, header, or lossy truncation - read piped stdin instead of injecting `.`; rg no longer searches the cwd on a pipe - pattern-less and format-flag commands run verbatim (rg --type-list, --files, grep -c/-o) - grouped/capped form only when capping actually shrinks the output - byte-for-byte tests vs grep -n across flags, pipes and edge cases (incl #1436) --- src/cmds/system/search.rs | 143 +++++++++++++++++----------- src/core/stream.rs | 11 +++ tests/grep_compress_test.rs | 30 +++--- tests/grep_faithful_format_test.rs | 147 +++++++++++++++++++++++++++++ tests/search_faithful_test.rs | 131 ++++++++++++++++++++++++- 5 files changed, 388 insertions(+), 74 deletions(-) create mode 100644 tests/grep_faithful_format_test.rs diff --git a/src/cmds/system/search.rs b/src/cmds/system/search.rs index 8b087d44ff..eab1162b91 100644 --- a/src/cmds/system/search.rs +++ b/src/cmds/system/search.rs @@ -6,7 +6,7 @@ //! used (see `Engine`); the compression is identical because both emit the same //! `file:line:content` shape. -use crate::core::stream::{exec_capture, CaptureResult}; +use crate::core::stream::{exec_capture, exec_capture_stdin, CaptureResult}; use crate::core::tracking; use crate::core::utils::{resolved_command, strip_ansi}; use crate::core::{args_utils, config}; @@ -308,38 +308,22 @@ fn engine_capture>( } cmd.arg("--"); cmd.args(paths); - exec_capture(&mut cmd).context("search failed") + exec_capture_stdin(&mut cmd).context("search failed") } -/// Runs the engine with no injected flags at all (already-minimal output forms). -fn engine_raw>( - engine: Engine, - extra_args: &[T], - patterns: &[String], - paths: &[String], -) -> Result { - let mut cmd = resolved_command(engine.bin()); - for a in extra_args { - cmd.arg(a.as_ref()); - } - for p in patterns { - cmd.args(["-e", p]); - } - cmd.arg("--"); - cmd.args(paths); - exec_capture(&mut cmd).context("search failed") -} - -#[allow(clippy::too_many_arguments)] +/// Runs the agent's command verbatim for forms RTK does not group: format/shape +/// flags and pattern-less modes (`--files`, `--type-list`). fn passthrough>( timer: &tracking::TimedExecution, engine: Engine, - extra_args: &[T], - patterns: &[String], - paths: &[String], + args: &[T], real_cmd: &str, ) -> Result { - let result = engine_raw(engine, extra_args, patterns, paths)?; + let mut cmd = resolved_command(engine.bin()); + for a in args { + cmd.arg(a.as_ref()); + } + let result = exec_capture_stdin(&mut cmd).context("search failed")?; print!("{}", strip_ansi(&result.stdout)); if !result.stderr.is_empty() { @@ -350,6 +334,12 @@ fn passthrough>( Ok(result.exit_code) } +fn has_short_flag(flags: &[String], ch: char) -> bool { + flags + .iter() + .any(|f| f.starts_with('-') && !f.starts_with("--") && f[1..].contains(ch)) +} + pub fn run( engine: Engine, max_line_len: usize, @@ -385,8 +375,7 @@ pub fn run( let (patterns, paths, extra_args) = extract_pattern_path(&args); if patterns.is_empty() { - eprintln!("rtk grep: pattern required (positional or -e)"); - return Ok(1); + return passthrough(&timer, engine, &args, &real_cmd); } let pattern_display = if patterns.len() == 1 { @@ -395,11 +384,6 @@ pub fn run( patterns.join("|") }; - let paths = if paths.is_empty() { - vec![".".to_string()] - } else { - paths - }; let path_display = paths.join(" "); if verbose > 0 { @@ -408,7 +392,7 @@ pub fn run( // format/shape flags (-c/-l/-o/...): already-minimal native output, passthrough. if has_format_flag(&extra_args) { - return passthrough(&timer, engine, &extra_args, &patterns, &paths, &real_cmd); + return passthrough(&timer, engine, &args, &real_cmd); } let result = engine_capture(engine, &extra_args, &patterns, &paths)?; @@ -435,7 +419,7 @@ pub fn run( // Safety net: unparseable shape → passthrough verbatim, never silently drop (#2333). let signal = unparsed_signal(&raw_output); if signal > 0 { - return passthrough(&timer, engine, &extra_args, &patterns, &paths, &real_cmd); + return passthrough(&timer, engine, &args, &real_cmd); } let context_re = if context_only { @@ -466,18 +450,49 @@ pub fn run( .filter(|(_, is_match, _)| *is_match) .count(); - let mut rtk_output = String::new(); - rtk_output.push_str(&format!( - "{} matches in {} files:\n\n", - total_matches, - by_file.len() - )); + // Mirror what the real command prints: the filename only when grep/rg would + // show one (multiple files, a directory, -r or -H), the line number only with + // -n. We force -nH--null for robust parsing, then drop what the engine itself + // would not have shown. + let show_file = by_file.len() > 1 + || paths.len() > 1 + || paths.iter().any(|p| std::path::Path::new(p).is_dir()) + || has_short_flag(&extra_args, 'H') + || has_short_flag(&extra_args, 'r') + || has_short_flag(&extra_args, 'R') + || extra_args + .iter() + .any(|f| f == "--with-filename" || f == "--recursive"); + // Always surface the line number (the openable position) unless the agent + // explicitly turned it off; the filename is the only conditional part. + let show_line = !has_short_flag(&extra_args, 'N') + && !extra_args.iter().any(|f| f == "--no-line-number"); + + // Faithful baseline: exactly what the real command prints, full content. + let mut plain = String::new(); + for line in raw_output.lines() { + let Some((file, line_num, is_match, content)) = parse_match_line(line) else { + continue; + }; + let sep = if is_match { ':' } else { '-' }; + if show_file { + plain.push_str(&file); + plain.push(sep); + } + if show_line { + plain.push_str(&line_num.to_string()); + plain.push(sep); + } + plain.push_str(content); + plain.push('\n'); + } - let mut shown = 0; + let per_file = config::limits().grep_max_per_file; let mut files: Vec<_> = by_file.iter().collect(); files.sort_by_key(|(f, _)| *f); - let per_file = config::limits().grep_max_per_file; + let mut body = String::new(); + let mut shown = 0; let mut skipped_files = 0; let mut skipped_block = String::new(); for (idx, (file, entries)) in files.into_iter().enumerate() { @@ -494,10 +509,16 @@ pub fn run( break; } let sep = if *is_match { ':' } else { '-' }; - rtk_output.push_str(&format!( - "{}{}{}{}{}\n", - file_display, sep, line_num, sep, content - )); + if show_file { + body.push_str(&file_display); + body.push(sep); + } + if show_line { + body.push_str(&line_num.to_string()); + body.push(sep); + } + body.push_str(content); + body.push('\n'); shown += 1; file_shown += 1; } @@ -512,22 +533,34 @@ pub fn run( match crate::core::tee::force_tee_tail_hint(&full_block, &grep_slug(idx, file), file_shown + 1) { Some(hint) => { - rtk_output.push_str(&format!(" +{} more in {} — {}\n", remaining, file_display, hint)) + body.push_str(&format!(" +{} more in {} {}\n", remaining, file_display, hint)) } - None => rtk_output.push_str(&format!(" +{} more in {}\n", remaining, file_display)), + None => body.push_str(&format!(" +{} more in {}\n", remaining, file_display)), } } if skipped_files > 0 { let hint = crate::core::tee::force_tee_tail_hint(&skipped_block, "grep_skipped", 1) - .map(|h| format!(" — {}", h)) + .map(|h| format!(" {}", h)) .unwrap_or_default(); - rtk_output.push_str(&format!("+{} more files{}\n", skipped_files, hint)); - } + body.push_str(&format!("+{} more files{}\n", skipped_files, hint)); + } + + // Switch to the grouped form only when capping actually shrank the output; + // otherwise emit the faithful baseline, so RTK never exceeds the real command. + let capped = shown < total_matches || skipped_files > 0; + let rtk_output = if capped { + format!( + "{} matches in {} files:\n\n{}", + total_matches, + by_file.len(), + body + ) + } else { + body + }; - // Never-worse: show plain `file:line:content` (NUL `-0` -> `:`) if grouping didn't shrink it. - let plain = raw_output.replace('\0', ":"); - let output = if rtk_output.len() < plain.len() { + let output = if capped && rtk_output.len() < plain.len() { rtk_output } else { plain diff --git a/src/core/stream.rs b/src/core/stream.rs index 02a6cfc798..cd573c4cc9 100644 --- a/src/core/stream.rs +++ b/src/core/stream.rs @@ -541,6 +541,17 @@ pub fn exec_capture(cmd: &mut Command) -> Result { }) } +/// Like [`exec_capture`] but inherits stdin so a wrapped engine can read a piped stdin. +pub fn exec_capture_stdin(cmd: &mut Command) -> Result { + cmd.stdin(Stdio::inherit()); + let output = cmd.output().context("Failed to execute command")?; + Ok(CaptureResult { + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + exit_code: status_to_exit_code(output.status), + }) +} + #[cfg(test)] pub(crate) mod tests { use super::*; diff --git a/tests/grep_compress_test.rs b/tests/grep_compress_test.rs index eb563bbea8..640e3cc63b 100644 --- a/tests/grep_compress_test.rs +++ b/tests/grep_compress_test.rs @@ -26,7 +26,7 @@ fn write_temp(content: &str) -> (tempfile::TempDir, std::path::PathBuf) { // --- context compression (the gain win) --- #[test] -fn after_context_yields_grouped_header_with_correct_match_count() { +fn single_file_context_shown_without_header() { if !rg_available() { return; } @@ -41,16 +41,12 @@ fn after_context_yields_grouped_header_with_correct_match_count() { let stdout = String::from_utf8_lossy(&out.stdout); assert!( - stdout.contains("1 matches"), - "header must count only the match line, not context lines:\n{stdout}" - ); - assert!( - stdout.contains("after1"), - "after-context line 1 missing:\n{stdout}" + !stdout.contains("matches in"), + "single-file search must not add a grouped header:\n{stdout}" ); assert!( - stdout.contains("after2"), - "after-context line 2 missing:\n{stdout}" + stdout.contains("after1") && stdout.contains("after2"), + "after-context lines must be shown:\n{stdout}" ); } @@ -61,7 +57,7 @@ fn after_context_uses_dash_separator_for_context_lines() { } let (_dir, path) = write_temp("MATCH\nafter1\n"); let out = rtk() - .args(["grep", "-A1", "MATCH", path.to_str().unwrap()]) + .args(["grep", "-nA1", "MATCH", path.to_str().unwrap()]) .output() .expect("rtk grep"); let stdout = String::from_utf8_lossy(&out.stdout); @@ -74,12 +70,12 @@ fn after_context_uses_dash_separator_for_context_lines() { } #[test] -fn plain_grep_still_shows_grouped_header() { +fn capped_single_file_shows_header() { if !rg_available() { return; } - let long = "x".repeat(120); - let content = format!("foo {long}\nbaz\nfoo {long}\n"); + let filler: String = (0..40).map(|i| format!("w{i} ")).collect(); + let content: String = (0..60).map(|i| format!("foo {i} {filler}\n")).collect(); let (_dir, path) = write_temp(&content); let out = rtk() .args(["grep", "foo", path.to_str().unwrap()]) @@ -89,7 +85,7 @@ fn plain_grep_still_shows_grouped_header() { assert!( stdout.contains("matches in"), - "grouped header must show when grouping compresses:\n{stdout}" + "header must show once capping compresses:\n{stdout}" ); } @@ -239,7 +235,7 @@ fn bulky_grep_yields_token_savings() { } let filler: String = (0..50).map(|i| format!("word{i} ")).collect(); let mut content = String::new(); - for i in 0..20 { + for i in 0..60 { content.push_str(&format!("MATCH line {i} {filler}\n")); } let (_dir, path) = write_temp(&content); @@ -310,8 +306,8 @@ fn small_grep_not_worse_than_plain() { let stdout = String::from_utf8_lossy(&out.stdout); assert!( - stdout.contains(":1:foo"), - "should show the match:\n{stdout}" + stdout.trim() == "1:foo", + "single-file grep must equal `grep -n` (position, no filename):\n{stdout}" ); assert!( !stdout.contains("matches in"), diff --git a/tests/grep_faithful_format_test.rs b/tests/grep_faithful_format_test.rs new file mode 100644 index 0000000000..4a9dd502b7 --- /dev/null +++ b/tests/grep_faithful_format_test.rs @@ -0,0 +1,147 @@ +#![cfg(unix)] +//! For un-capped searches `rtk grep X` must be byte-identical to `grep -n X`: +//! line number always, filename only when grep itself prints one. Covers content +//! and regex edge cases (colons, digits, shell metachars, unicode, colon-in-path) +//! that must never be misparsed. + +use std::io::Write; +use std::process::{Command, Stdio}; + +fn rtk_grep(args: &[&str]) -> (String, Option) { + let mut a = vec!["grep"]; + a.extend_from_slice(args); + let out = Command::new(env!("CARGO_BIN_EXE_rtk")) + .args(&a) + .output() + .expect("rtk"); + ( + String::from_utf8_lossy(&out.stdout).into_owned(), + out.status.code(), + ) +} + +fn grep_n(args: &[&str]) -> (String, Option) { + let mut a = vec!["-n"]; + a.extend_from_slice(args); + let out = Command::new("grep").args(&a).output().expect("grep"); + ( + String::from_utf8_lossy(&out.stdout).into_owned(), + out.status.code(), + ) +} + +fn assert_eq_grep_n(args: &[&str]) { + let (rtk, rc) = rtk_grep(args); + let (grep, gc) = grep_n(args); + assert_eq!(rtk, grep, "stdout mismatch for {args:?}"); + assert_eq!(rc, gc, "exit code mismatch for {args:?}"); +} + +fn write(dir: &std::path::Path, name: &str, body: &str) -> String { + let p = dir.join(name); + std::fs::write(&p, body).expect("write"); + p.to_str().unwrap().to_string() +} + +#[test] +fn single_multi_recursive_and_h_match_grep_n() { + let d = tempfile::tempdir().unwrap(); + let f1 = write(d.path(), "f1.txt", "apple\nzebra apple\nbanana\n"); + let f2 = write(d.path(), "f2.txt", "apricot\n"); + assert_eq_grep_n(&["apple", &f1]); // single: position only + assert_eq_grep_n(&["a", &f1, &f2]); // multi: file + position + assert_eq_grep_n(&["-H", "apple", &f1]); // -H forces filename on a single file + assert_eq_grep_n(&["-r", "a", d.path().to_str().unwrap()]); // recursive +} + +#[test] +fn no_match_matches_grep_n() { + let d = tempfile::tempdir().unwrap(); + let f = write(d.path(), "f.txt", "hello\n"); + assert_eq_grep_n(&["zzz_no_match_xyz", &f]); // empty stdout, exit 1 +} + +#[test] +fn nasty_content_is_not_misparsed() { + let d = tempfile::tempdir().unwrap(); + let f = write( + d.path(), + "n.txt", + "12:34 looks like a line number\na::b::c ClassRegistry::init('x')\nport :8080: here\n$(rm -rf /) `whoami` && echo\n中文 テスト مرحبا\n", + ); + assert_eq_grep_n(&[":", &f]); + assert_eq_grep_n(&["ClassRegistry", &f]); + assert_eq_grep_n(&["中文", &f]); + assert_eq_grep_n(&["whoami", &f]); +} + +#[test] +fn regex_metacharacters_match_grep_n() { + let d = tempfile::tempdir().unwrap(); + let f = write(d.path(), "r.txt", "a.b\naxb\nfoo.bar\n[x]\n"); + assert_eq_grep_n(&["a.b", &f]); // . is any-char + assert_eq_grep_n(&["-F", "a.b", &f]); // fixed-string + assert_eq_grep_n(&["^foo", &f]); // anchor + assert_eq_grep_n(&["-E", "ax?b", &f]); // ERE +} + +#[test] +fn context_flags_match_grep_n() { + let d = tempfile::tempdir().unwrap(); + let f = write(d.path(), "c.txt", "x\nMATCH\ny\nz\n"); + assert_eq_grep_n(&["-A1", "MATCH", &f]); + assert_eq_grep_n(&["-B1", "MATCH", &f]); + assert_eq_grep_n(&["-C1", "MATCH", &f]); +} + +// #1436: a single-file `-n` search with `::` content and a BRE pattern with +// literal `()` must equal grep exactly — no broadened matches, and no synthetic +// `[file]` bucket from splitting on `::`. +#[test] +fn issue_1436_colons_and_literal_parens() { + let d = tempfile::tempdir().unwrap(); + let f = write( + d.path(), + "shell.php", + "use Util\\ClassRegistry;\nclass Foo {\n public function run() {\n $this->m = ClassRegistry::init('Collections.QueueProcess');\n try {\n deleteRow($id);\n $this->delete();\n } catch (\\Exception $e) {\n } finally {}\n }\n private function delete() {}\n}\n", + ); + assert_eq_grep_n(&[ + "private function delete\\|try\\|catch\\|finally\\|delete()", + &f, + ]); + assert_eq_grep_n(&["init", &f]); // ClassRegistry::init must stay one intact line +} + +#[test] +fn colon_in_filename_matches_grep_n() { + let d = tempfile::tempdir().unwrap(); + let f1 = write(d.path(), "weird:name.txt", "hit\n"); + let f2 = write(d.path(), "other.txt", "hit\n"); + assert_eq_grep_n(&["hit", &f1, &f2]); // colon in a path must not fool the parser +} + +#[test] +fn case_insensitive_and_invert_match_grep_n() { + let d = tempfile::tempdir().unwrap(); + let f = write(d.path(), "i.txt", "Apple\nbanana\nAPPLE\n"); + assert_eq_grep_n(&["-i", "apple", &f]); + assert_eq_grep_n(&["-v", "apple", &f]); +} + +#[test] +fn piped_stdin_matches_grep_n() { + let input = "apple\nzebra\napple pie\n"; + let feed = |cmd: &mut Command| { + let mut c = cmd + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn"); + c.stdin.take().unwrap().write_all(input.as_bytes()).unwrap(); + String::from_utf8_lossy(&c.wait_with_output().unwrap().stdout).into_owned() + }; + let rtk = feed(Command::new(env!("CARGO_BIN_EXE_rtk")).args(["grep", "apple"])); + let grep = feed(Command::new("grep").args(["-n", "apple"])); + assert_eq!(rtk, grep, "piped stdin must equal grep -n"); +} diff --git a/tests/search_faithful_test.rs b/tests/search_faithful_test.rs index 537311979c..3fdfddc794 100644 --- a/tests/search_faithful_test.rs +++ b/tests/search_faithful_test.rs @@ -3,12 +3,35 @@ //! with its own regex dialect and ignore semantics. rtk filters output noise only; //! it never substitutes one engine for the other (the bug this PR removes). -use std::process::Command; +use std::io::Write; +use std::process::{Command, Stdio}; fn rtk() -> Command { Command::new(env!("CARGO_BIN_EXE_rtk")) } +/// Run rtk with `input` fed on stdin; returns (stdout, exit_code). +fn rtk_stdin(input: &str, args: &[&str]) -> (String, Option) { + let mut child = rtk() + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn rtk"); + child + .stdin + .take() + .expect("stdin") + .write_all(input.as_bytes()) + .expect("write stdin"); + let out = child.wait_with_output().expect("wait"); + ( + String::from_utf8_lossy(&out.stdout).into_owned(), + out.status.code(), + ) +} + fn rg_available() -> bool { Command::new("rg") .arg("--version") @@ -117,7 +140,7 @@ fn bulky_rg_yields_token_savings() { } let filler: String = (0..50).map(|i| format!("word{i} ")).collect(); let mut content = String::new(); - for i in 0..20 { + for i in 0..60 { content.push_str(&format!("MATCH line {i} {filler}\n")); } let (_dir, path) = write_temp(&content); @@ -200,3 +223,107 @@ fn rg_glob_flag_filters_the_file_set() { "rg -g '*.rs' must exclude files outside the glob:\n{stdout}" ); } + +// --- piped stdin: read the pipe, never inject a path --- + +#[test] +fn grep_reads_piped_stdin() { + let (out, code) = rtk_stdin("alpha\nmatch me here\nomega\n", &["grep", "match"]); + assert_eq!( + code, + Some(0), + "stdin match must exit 0, got {code:?}:\n{out}" + ); + assert!( + out.contains("match me here"), + "grep must read stdin and emit the match:\n{out}" + ); + assert!( + !out.contains("Is a directory"), + "RTK must not inject '.' and break a stdin pipe:\n{out}" + ); +} + +#[test] +fn rg_reads_piped_stdin_without_flooding_cwd() { + if !rg_available() { + return; + } + // `fn` matches thousands of repo lines; reading stdin must yield only the pipe. + let (out, code) = rtk_stdin("fn stdin_only_line\n", &["rg", "fn"]); + assert_eq!( + code, + Some(0), + "stdin match must exit 0, got {code:?}:\n{out}" + ); + assert!( + out.contains("stdin_only_line"), + "rg must read stdin and emit the match:\n{out}" + ); + assert!( + !out.contains(".rs:") && !out.contains("src/"), + "rg must read stdin, not flood the working tree:\n{out}" + ); +} + +#[test] +fn grep_counts_piped_stdin_via_passthrough() { + // `-c` routes to the passthrough path; it must still read the pipe, not null stdin. + let (out, code) = rtk_stdin("hit\nmiss\nhit\n", &["grep", "-c", "hit"]); + assert_eq!( + code, + Some(0), + "stdin count must exit 0, got {code:?}:\n{out}" + ); + assert_eq!( + out.trim(), + "2", + "grep -c must count matches from stdin:\n{out}" + ); +} + +// --- pattern-less commands run instead of being blocked --- + +#[test] +fn rg_type_list_passes_through() { + if !rg_available() { + return; + } + let out = rtk().args(["rg", "--type-list"]).output().expect("rtk rg"); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + assert_eq!(out.status.code(), Some(0), "--type-list must exit 0"); + assert!( + stdout.lines().count() > 10, + "--type-list must produce the real type list:\n{stdout}" + ); + assert!( + !stdout.contains("pattern required") && !stderr.contains("pattern required"), + "valid pattern-less command must not be blocked:\n{stdout}{stderr}" + ); + assert!( + !stderr.contains("rtk grep:"), + "an rg command must never be labelled 'rtk grep:':\n{stderr}" + ); +} + +#[test] +fn rg_files_lists_the_given_dir() { + if !rg_available() { + return; + } + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write(dir.path().join("only_here.txt"), "x\n").expect("write"); + let path = dir.path().to_str().unwrap(); + + let out = rtk() + .args(["rg", "--files", path]) + .output() + .expect("rtk rg"); + let stdout = String::from_utf8_lossy(&out.stdout); + assert_eq!(out.status.code(), Some(0), "--files must exit 0"); + assert!( + stdout.contains("only_here.txt"), + "--files must list the given dir, not the cwd:\n{stdout}" + ); +} From c66e5103729251022effe928a2e3e531093df35d Mon Sep 17 00:00:00 2001 From: Adrien Eppling Date: Fri, 26 Jun 2026 17:34:26 +0200 Subject: [PATCH 48/50] test(grep): cover #1436 comment-reported cases - leading ^ anchor must stay scoped to the path (tenequm) - line ending in `:` keeps full content (BTCAlchemist) - rg surfaces a regex parse error as exit 2, not a silent 0 (jhagberg) --- tests/grep_faithful_format_test.rs | 17 +++++++++++++++++ tests/search_faithful_test.rs | 19 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/tests/grep_faithful_format_test.rs b/tests/grep_faithful_format_test.rs index 4a9dd502b7..5ce9e3a7cd 100644 --- a/tests/grep_faithful_format_test.rs +++ b/tests/grep_faithful_format_test.rs @@ -112,6 +112,23 @@ fn issue_1436_colons_and_literal_parens() { assert_eq_grep_n(&["init", &f]); // ClassRegistry::init must stay one intact line } +// #1436 (comments): a leading `^` anchor must stay scoped to the given file +// (tenequm), and a line ending in `:` (Python `if x:`) must keep full content +// (BTCAlchemist). +#[test] +fn issue_1436_anchor_scope_and_trailing_colon() { + let d = tempfile::tempdir().unwrap(); + let f = write(d.path(), "code.rs", "pub fn a\nprivate b\npub fn c\n"); + let py = write( + d.path(), + "t.py", + "x = 1\nif crypto >= MAX_CRYPTO:\nclass Foo:\n", + ); + assert_eq_grep_n(&["^pub", &f]); // anchored pattern must not escape the path + assert_eq_grep_n(&["MAX_CRYPTO", &py]); // trailing-colon line kept intact + assert_eq_grep_n(&["class", &py]); +} + #[test] fn colon_in_filename_matches_grep_n() { let d = tempfile::tempdir().unwrap(); diff --git a/tests/search_faithful_test.rs b/tests/search_faithful_test.rs index 3fdfddc794..e821d39eba 100644 --- a/tests/search_faithful_test.rs +++ b/tests/search_faithful_test.rs @@ -327,3 +327,22 @@ fn rg_files_lists_the_given_dir() { "--files must list the given dir, not the cwd:\n{stdout}" ); } + +// #1436 (jhagberg): a pattern rg rejects must surface rg's error (exit 2), never +// be swallowed into a silent "0 matches" (exit 1) that reads as a clean no-match. +#[test] +fn rg_surfaces_regex_error_not_silent_zero() { + if !rg_available() { + return; + } + let (_dir, path) = write_temp("GET /objects/{path}\nfilePath: x\n"); + let out = rtk() + .args(["rg", "objects|{path}", path.to_str().unwrap()]) + .output() + .expect("rtk rg"); + assert_eq!( + out.status.code(), + Some(2), + "an rg regex parse error must surface as exit 2, not a silent 0/1" + ); +} From 059131cd56d77699756fb9fa195b41217dbd2bcb Mon Sep 17 00:00:00 2001 From: Adrien Eppling Date: Fri, 26 Jun 2026 17:44:29 +0200 Subject: [PATCH 49/50] test(grep): rename compress suite to search_compress, cover binary skip - grep_compress_test.rs -> search_compress_test.rs (tests the shared grep/rg filter) - binary match skipped as noise by default, -a opts back in - fix parse_flags doc: -I is an intentional binary skip, not a no-op --- src/cmds/system/search.rs | 5 ++- ...mpress_test.rs => search_compress_test.rs} | 33 +++++++++++++++++-- 2 files changed, 33 insertions(+), 5 deletions(-) rename tests/{grep_compress_test.rs => search_compress_test.rs} (88%) diff --git a/src/cmds/system/search.rs b/src/cmds/system/search.rs index eab1162b91..4409c4f7a9 100644 --- a/src/cmds/system/search.rs +++ b/src/cmds/system/search.rs @@ -279,9 +279,8 @@ impl Engine { self.bin() } - /// Parse aids only: line numbers + always-shown filename, NUL-separated so the - /// regroup is unambiguous (#1436), binary noise skipped. None of these change - /// which lines match. + /// `-n -H --null` are parse aids (NUL keeps the regroup unambiguous, #1436); + /// `-I` skips binary noise (-a overrides). fn parse_flags(self) -> &'static [&'static str] { match self { Engine::Grep => &["-n", "-H", "-I", "--null"], diff --git a/tests/grep_compress_test.rs b/tests/search_compress_test.rs similarity index 88% rename from tests/grep_compress_test.rs rename to tests/search_compress_test.rs index 640e3cc63b..1a36c08399 100644 --- a/tests/grep_compress_test.rs +++ b/tests/search_compress_test.rs @@ -1,6 +1,7 @@ #![cfg(unix)] -//! Integration tests for the GROUP path with context flags (-A/-B/-C) and the -//! safety-net passthrough for flags that break the NUL-based reparse. +//! Integration tests for the shared grep/rg compression filter: the GROUP path +//! with context flags (-A/-B/-C) and the safety-net passthrough for flags (some +//! grep-only like -I, some rg-only like --heading/-p) that break the NUL reparse. use std::process::Command; @@ -347,3 +348,31 @@ fn bundled_files_with_matches_cluster_lists_files() { "-rln must not list non-matching files:\n{stdout}" ); } + +// A match inside a binary file is noise (grep prints only a "binary file matches" +// notice); skip it by default, but `-a` lets the agent opt back into the content. +#[test] +fn binary_match_is_skipped_unless_text_requested() { + let dir = tempfile::tempdir().expect("tempdir"); + let p = dir.path().join("blob.bin"); + std::fs::write(&p, b"SECRET\x00\x01binary\xff\xfe").expect("write"); + let path = p.to_str().unwrap(); + + let out = rtk().args(["grep", "SECRET", path]).output().expect("rtk"); + assert_eq!( + out.status.code(), + Some(1), + "binary match must be skipped as noise by default" + ); + assert!(out.stdout.is_empty(), "no binary content by default"); + + let out = rtk() + .args(["grep", "-a", "SECRET", path]) + .output() + .expect("rtk -a"); + assert_eq!(out.status.code(), Some(0), "-a must surface the match"); + assert!( + String::from_utf8_lossy(&out.stdout).contains("SECRET"), + "-a must show the binary content" + ); +} From 05f3c54886e6d39476b1e65275c599fe4dc470a3 Mon Sep 17 00:00:00 2001 From: Adrien Eppling Date: Fri, 26 Jun 2026 19:43:51 +0200 Subject: [PATCH 50/50] fix(grep): surface the engine error and exit code, add nothing - drop the synthetic "search failed with exit code N" line (an rtk addition) - surface the engine stderr verbatim in every path, incl. partial match + error - remove the now-vestigial is_grep_error_exit (the "0 matches" it guarded is gone) - error/exit matrix tests vs real grep/rg (#2465: an error is never a silent no-match) --- src/cmds/system/search.rs | 48 ++++----------- tests/search_error_test.rs | 123 +++++++++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+), 37 deletions(-) create mode 100644 tests/search_error_test.rs diff --git a/src/cmds/system/search.rs b/src/cmds/system/search.rs index 4409c4f7a9..ffdd93c166 100644 --- a/src/cmds/system/search.rs +++ b/src/cmds/system/search.rs @@ -326,7 +326,7 @@ fn passthrough>( print!("{}", strip_ansi(&result.stdout)); if !result.stderr.is_empty() { - eprint!("{}", result.stderr.trim()); + eprint!("{}", result.stderr); } timer.track_passthrough(real_cmd, &format!("rtk {} (passthrough)", real_cmd)); @@ -399,28 +399,21 @@ pub fn run( let exit_code = result.exit_code; let raw_output = result.stdout.clone(); + // Unparseable shape re-runs verbatim below (with its own stderr), so handle it + // before surfacing this run's stderr (#2333). + if unparsed_signal(&raw_output) > 0 { + return passthrough(&timer, engine, &args, &real_cmd); + } + + if !result.stderr.is_empty() { + eprint!("{}", result.stderr); + } + if result.stdout.trim().is_empty() { - if is_grep_error_exit(exit_code) { - if !result.stderr.trim().is_empty() { - eprintln!("{}", result.stderr.trim()); - } - let msg = format!("search failed with exit code {}", exit_code); - timer.track(&real_cmd, &rtk_label, &raw_output, &msg); - eprintln!("{}", msg); - return Ok(exit_code); - } - // grep/rg print nothing on a clean no-match; a "0 matches" line would - // exceed the real command's output, so emit nothing (never-worse). timer.track(&real_cmd, &rtk_label, &raw_output, ""); return Ok(exit_code); } - // Safety net: unparseable shape → passthrough verbatim, never silently drop (#2333). - let signal = unparsed_signal(&raw_output); - if signal > 0 { - return passthrough(&timer, engine, &args, &real_cmd); - } - let context_re = if context_only { Regex::new(&format!( "(?i).{{0,20}}{}.*", @@ -697,29 +690,10 @@ fn compact_path(path: &str) -> String { ) } -/// grep/rg convention: exit 1 = no match found (normal), exit >= 2 = real -/// error (bad regex, tool crash, missing binary). An error must surface to the -/// user, never be silently reported as a false "0 matches". -fn is_grep_error_exit(exit_code: i32) -> bool { - exit_code >= 2 -} - #[cfg(test)] mod tests { use super::*; - #[test] - fn test_is_grep_error_exit() { - // exit 0 = matches, exit 1 = no match: both normal, not errors. - assert!(!is_grep_error_exit(0)); - assert!(!is_grep_error_exit(1)); - // exit >= 2 = real error (bad regex, tool crash, missing binary). - // Must surface, never become a false "0 matches". - assert!(is_grep_error_exit(2)); - assert!(is_grep_error_exit(3)); - assert!(is_grep_error_exit(127)); - } - #[test] fn test_clean_line() { let line = " const result = someFunction();"; diff --git a/tests/search_error_test.rs b/tests/search_error_test.rs new file mode 100644 index 0000000000..b2fdedf58d --- /dev/null +++ b/tests/search_error_test.rs @@ -0,0 +1,123 @@ +#![cfg(unix)] +//! Error/exit faithfulness: rtk surfaces the engine's own stderr and propagates +//! its exit code, adding nothing (no synthetic "search failed" line). An engine +//! error (exit >=2) must never look like a silent no-match (#2465 / #1436). + +use std::process::Command; + +fn rtk(args: &[&str]) -> std::process::Output { + Command::new(env!("CARGO_BIN_EXE_rtk")) + .args(args) + .output() + .expect("rtk") +} + +fn grep_exit(args: &[&str]) -> Option { + Command::new("grep") + .args(args) + .output() + .expect("grep") + .status + .code() +} + +fn rg_available() -> bool { + Command::new("rg") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +fn write_temp(content: &str) -> (tempfile::TempDir, String) { + let d = tempfile::tempdir().expect("tempdir"); + let p = d.path().join("ok.txt"); + std::fs::write(&p, content).expect("write"); + let s = p.to_str().unwrap().to_string(); + (d, s) +} + +#[test] +fn file_not_found_propagates_exit_and_surfaces_error() { + let d = tempfile::tempdir().unwrap(); + let missing = d.path().join("nope.txt"); + let m = missing.to_str().unwrap(); + let out = rtk(&["grep", "fn", m]); + assert_eq!( + out.status.code(), + grep_exit(&["fn", m]), + "exit must match grep" + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("No such file"), + "the engine's error must be surfaced:\n{stderr}" + ); + assert!( + !stderr.contains("search failed"), + "no synthetic line:\n{stderr}" + ); +} + +#[test] +fn bad_regex_surfaces_error_never_silent_no_match() { + let (_d, f) = write_temp("fn alpha\n"); + let out = rtk(&["grep", "[", &f]); + assert_eq!( + out.status.code(), + Some(2), + "a bad regex must propagate exit 2, not a silent exit-1 no-match" + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(!stderr.is_empty(), "the engine error must surface"); + assert!( + !stderr.contains("search failed"), + "nothing added:\n{stderr}" + ); +} + +#[test] +fn partial_match_with_error_surfaces_both() { + let (_d, f) = write_temp("fn alpha\nfn beta\n"); + let d2 = tempfile::tempdir().unwrap(); + let missing = d2.path().join("nope.txt"); + let out = rtk(&["grep", "fn", &f, missing.to_str().unwrap()]); + assert_eq!(out.status.code(), Some(2), "the error exit must propagate"); + assert!( + String::from_utf8_lossy(&out.stderr).contains("No such file"), + "the file error must surface alongside the matches" + ); + assert!( + !out.stdout.is_empty(), + "the matches from the readable file must still be shown" + ); +} + +#[test] +fn no_match_stays_exit_1_and_empty() { + let (_d, f) = write_temp("fn alpha\n"); + let out = rtk(&["grep", "zzzz_no_match", &f]); + assert_eq!(out.status.code(), Some(1), "no-match is exit 1"); + assert!( + out.stdout.is_empty() && out.stderr.is_empty(), + "no-match emits nothing" + ); +} + +#[test] +fn rg_bad_regex_surfaces_error_exit_2() { + if !rg_available() { + return; + } + let (_d, f) = write_temp("GET /objects/{path}\n"); + let out = rtk(&["rg", "a|{path}", &f]); + assert_eq!( + out.status.code(), + Some(2), + "rg's regex parse error must propagate exit 2" + ); + assert!( + !String::from_utf8_lossy(&out.stderr).contains("search failed"), + "nothing added" + ); +}