From a7d0b28d99aad1084c557c41e21e4ecc63fa8037 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A0=20Vi=E1=BB=87t=20Ti=E1=BA=BFn?= <34891363+tienthegainz@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:06:24 +0700 Subject: [PATCH 1/7] feat(system): native Windows ls/grep/wc/tree + PowerShell cmdlet rewrites --- .github/copilot-instructions.md | 28 ++++ src/cmds/system/README.md | 39 ++++++ src/cmds/system/ls.rs | 125 ++++++++++++++++- src/cmds/system/tree.rs | 204 ++++++++++++++++++++++++++-- src/cmds/system/wc_cmd.rs | 234 +++++++++++++++++++++++++++++++- 5 files changed, 618 insertions(+), 12 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index e2681b6beb..3fc45705ca 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -64,3 +64,31 @@ For full details see [ARCHITECTURE.md](../docs/contributing/ARCHITECTURE.md) and - **Branch naming**: `fix(scope):`, `feat(scope):`, `chore(scope):` where scope is the affected component. For the full contribution workflow, design philosophy, and new-filter checklist, see [CONTRIBUTING.md](../CONTRIBUTING.md). + + +# RTK — Token-Optimized CLI + +**rtk** is a CLI proxy that filters and compresses command outputs, saving 60-90% tokens. + +## Rule + +Always prefix shell commands with `rtk`: + +```bash +# Instead of: Use: +git status rtk git status +git log -10 rtk git log -10 +cargo test rtk cargo test +docker ps rtk docker ps +kubectl get pods rtk kubectl pods +``` + +## Meta commands (use directly) + +```bash +rtk gain # Token savings dashboard +rtk gain --history # Per-command savings history +rtk discover # Find missed rtk opportunities +rtk proxy # Run raw (no filtering) but track usage +``` + \ No newline at end of file diff --git a/src/cmds/system/README.md b/src/cmds/system/README.md index ae475befbd..3e34e126fb 100644 --- a/src/cmds/system/README.md +++ b/src/cmds/system/README.md @@ -9,6 +9,45 @@ - `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) +## Windows / no-coreutils fallback + +`ls`, `grep`, `wc`, and `tree` shell out to Unix binaries that aren't present on +a stock Windows install. Each of these now has a **native Rust execution path** +used when the underlying binary is unavailable (detected via +`core::utils::tool_exists`; `tree` also always goes native on Windows because +`tree.com` rejects the flags rtk uses): + +- `ls.rs` — `run_native` synthesizes `ls -la`-style lines from `std::fs` and + reuses `compact_ls`. POSIX permission bits don't exist on Windows, so the `-l` + octal column is approximate there. +- `wc_cmd.rs` — `run_native` counts lines/words/bytes/chars in Rust and reuses + `filter_wc_output`. +- `tree.rs` — `run_native` does a recursive `std::fs` walk, pruning `NOISE_DIRS` + unless `-a`. +- `grep_cmd.rs` — `native_grep` (final fallback after `rg` then `grep`) walks + files with the `ignore` crate and matches with `regex`, emitting the same + NUL-separated `path\0line:content` format the parser expects. It respects + `.gitignore` and skips hidden files, which differs slightly from + `rg --no-ignore-vcs`. + +These native paths reuse the existing compression filters, so token-savings +behavior is identical to the Unix spawn path. + +## PowerShell cmdlet rewrites + +`discover/registry.rs::try_rewrite_powershell` maps common, **non-piped** +PowerShell cmdlets to their rtk equivalents so the hook saves tokens on Windows: + +| Cmdlet (and aliases) | rtk equivalent | +|---|---| +| `Get-Content` / `gc` / `type` | `rtk read` (`-TotalCount`→`--max-lines`, `-Tail`→`--tail-lines`) | +| `Get-ChildItem` / `gci` / `dir` | `rtk ls` (`-Recurse`→`rtk tree`, `-Force`→`-a`) | +| `Select-String` / `sls` | `rtk grep` (`-i` added unless `-CaseSensitive`) | + +Piped/compound cmdlet invocations are intentionally left untouched to avoid +breaking PowerShell pipeline semantics. + ## Cross-command - `format_cmd` routes to `cmds/js/prettier_cmd` and `cmds/python/ruff_cmd` + diff --git a/src/cmds/system/ls.rs b/src/cmds/system/ls.rs index 767470fd19..1a268361df 100644 --- a/src/cmds/system/ls.rs +++ b/src/cmds/system/ls.rs @@ -2,8 +2,9 @@ use super::constants::NOISE_DIRS; use crate::core::runner::{self, RunOptions}; +use crate::core::tracking::TimedExecution; use crate::core::truncate::{reduced, CAP_WARNINGS}; -use crate::core::utils::resolved_command; +use crate::core::utils::{resolved_command, tool_exists}; use anyhow::Result; use regex::Regex; use std::io::IsTerminal; @@ -48,6 +49,12 @@ pub fn run(args: &[String], verbose: u8) -> Result { .map(|s| s.as_str()) .collect(); + // On Windows (and any host lacking the Unix `ls` binary) fall back to a + // native Rust listing so `rtk ls` works without coreutils installed. + if !tool_exists("ls") { + return run_native(&paths, show_all, show_long, verbose); + } + let mut cmd = resolved_command("ls"); cmd.env("LC_ALL", "C"); cmd.arg("-la"); @@ -126,6 +133,88 @@ pub fn run(args: &[String], verbose: u8) -> Result { ) } +/// Native `ls` implementation used when the Unix binary is unavailable +/// (e.g. on stock Windows). Synthesizes `ls -la`-style lines from the +/// filesystem and reuses [`compact_ls`] so output matches the spawn path. +/// +/// Note: POSIX permission bits don't exist on Windows, so synthetic perms +/// (`drwxr-xr-x` for dirs, `-rw-r--r--` for files) are emitted; the `-l` +/// octal column is therefore approximate on Windows. +fn run_native(paths: &[&str], show_all: bool, show_long: bool, verbose: u8) -> Result { + let timer = TimedExecution::start(); + let targets: Vec<&str> = if paths.is_empty() { vec!["."] } else { paths.to_vec() }; + + let mut raw = String::new(); + let mut exit_code = 0; + + for target in &targets { + match std::fs::metadata(target) { + Ok(meta) if meta.is_dir() => { + match std::fs::read_dir(target) { + Ok(entries) => { + let mut lines: Vec = Vec::new(); + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + if !show_all && name.starts_with('.') { + continue; + } + let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false); + let size = entry.metadata().map(|m| m.len()).unwrap_or(0); + lines.push(synth_ls_line(&name, is_dir, size)); + } + lines.sort(); + for line in lines { + raw.push_str(&line); + raw.push('\n'); + } + } + Err(e) => { + eprintln!("ls: {target}: {e}"); + exit_code = 2; + } + } + } + Ok(meta) => { + // A file argument: ls prints just the name. + raw.push_str(&synth_ls_line(target, false, meta.len())); + raw.push('\n'); + } + Err(e) => { + eprintln!("ls: {target}: {e}"); + exit_code = 2; + } + } + } + + let (entries, summary, _parsed) = compact_ls(&raw, show_all, show_long); + let is_tty = std::io::stdout().is_terminal(); + let filtered = if is_tty { + format!("{}{}", entries, summary) + } else { + entries + }; + + if verbose > 0 { + eprintln!("ls (native): {} target(s)", targets.len()); + } + + print!("{filtered}"); + timer.track( + &format!("ls {}", targets.join(" ")), + "rtk ls", + &raw, + &filtered, + ); + Ok(exit_code) +} + +/// Build a synthetic `ls -la` line that [`parse_ls_line`] can parse. +/// The date is a fixed valid anchor (not shown in compact output). +fn synth_ls_line(name: &str, is_dir: bool, size: u64) -> String { + let perms = if is_dir { "drwxr-xr-x" } else { "-rw-r--r--" }; + format!("{perms} 1 user group {size} Jan 1 00:00 {name}") +} + /// Format bytes into human-readable size fn human_size(bytes: u64) -> String { if bytes >= 1_048_576 { @@ -353,6 +442,40 @@ fn compact_ls(raw: &str, show_all: bool, show_long: bool) -> (String, String, us mod tests { use super::*; + #[test] + fn test_synth_ls_line_parses() { + // A synthetic native line must be parseable by parse_ls_line. + let line = synth_ls_line("constants.rs", false, 542); + let parsed = parse_ls_line(&line).expect("synth line should parse"); + let (file_type, _perms, size, name) = parsed; + assert_eq!(file_type, '-'); + assert_eq!(size, 542); + assert_eq!(name, "constants.rs"); + } + + #[test] + fn test_synth_ls_line_dir() { + let line = synth_ls_line("src", true, 0); + let (file_type, _perms, _size, name) = parse_ls_line(&line).expect("dir line parses"); + assert_eq!(file_type, 'd'); + assert_eq!(name, "src"); + } + + #[test] + fn test_native_raw_round_trips_through_compact_ls() { + // Simulate native run_native output and verify compact_ls compresses it. + let mut raw = String::new(); + raw.push_str(&synth_ls_line("src", true, 0)); + raw.push('\n'); + raw.push_str(&synth_ls_line("Cargo.toml", false, 1234)); + raw.push('\n'); + let (entries, _summary, parsed) = compact_ls(&raw, false, false); + assert_eq!(parsed, 2); + assert!(entries.contains("src/")); + assert!(entries.contains("Cargo.toml")); + assert!(entries.contains("1.2K")); + } + #[test] fn test_compact_basic() { let input = "total 48\n\ diff --git a/src/cmds/system/tree.rs b/src/cmds/system/tree.rs index 576e6c8006..28e8218c8c 100644 --- a/src/cmds/system/tree.rs +++ b/src/cmds/system/tree.rs @@ -8,18 +8,17 @@ use super::constants::NOISE_DIRS; use crate::core::runner::{self, RunOptions}; +use crate::core::tracking::TimedExecution; use crate::core::utils::{resolved_command, tool_exists}; use anyhow::Result; +use std::path::Path; pub fn run(args: &[String], verbose: u8) -> Result { - if !tool_exists("tree") { - anyhow::bail!( - "tree command not found. Install it first:\n\ - - macOS: brew install tree\n\ - - Ubuntu/Debian: sudo apt install tree\n\ - - Fedora/RHEL: sudo dnf install tree\n\ - - Arch: sudo pacman -S tree" - ); + // Windows ships `tree.com`, which is NOT the Unix `tree` and rejects the + // flags rtk uses (e.g. `-I ` → "Too many parameters"). Use a + // native Rust walk on Windows or whenever the Unix binary is unavailable. + if cfg!(windows) || !tool_exists("tree") { + return run_native(args, verbose); } let mut cmd = resolved_command("tree"); @@ -62,6 +61,162 @@ pub fn run(args: &[String], verbose: u8) -> Result { ) } +/// Options parsed from native `tree` args. +struct TreeOpts { + show_all: bool, + dirs_only: bool, + max_depth: Option, + root: String, +} + +fn parse_tree_args(args: &[String]) -> TreeOpts { + let mut show_all = false; + let mut dirs_only = false; + let mut max_depth = None; + let mut positionals: Vec = Vec::new(); + + let mut i = 0; + while i < args.len() { + let a = &args[i]; + if a == "-a" || a == "--all" { + show_all = true; + } else if a == "-d" { + dirs_only = true; + } else if a == "-L" { + if let Some(n) = args.get(i + 1).and_then(|v| v.parse::().ok()) { + max_depth = Some(n); + i += 1; + } + } else if let Some(rest) = a.strip_prefix("-L") { + if let Ok(n) = rest.parse::() { + max_depth = Some(n); + } + } else if a.starts_with('-') { + // Ignore other flags (e.g. -I value pairs) for the native path. + if a == "-I" { + i += 1; // skip its pattern argument + } + } else { + positionals.push(a.clone()); + } + i += 1; + } + + TreeOpts { + show_all, + dirs_only, + max_depth, + root: positionals.first().cloned().unwrap_or_else(|| ".".to_string()), + } +} + +/// Native `tree` implementation (cross-platform) used on Windows or when the +/// Unix `tree` binary is unavailable. Prunes [`NOISE_DIRS`] unless `-a`. +fn run_native(args: &[String], verbose: u8) -> Result { + let timer = TimedExecution::start(); + let opts = parse_tree_args(args); + + let mut out = String::new(); + out.push_str(&opts.root); + out.push('\n'); + + let mut dirs = 0usize; + let mut files = 0usize; + render_dir( + Path::new(&opts.root), + "", + &opts, + 1, + &mut out, + &mut dirs, + &mut files, + ); + + out.push('\n'); + out.push_str(&format!("{dirs} directories, {files} files\n")); + + let filtered = filter_tree_output(&out); + + if verbose > 0 { + eprintln!("tree (native): {dirs} dirs, {files} files"); + } + + print!("{filtered}"); + timer.track( + &format!("tree {}", args.join(" ")), + "rtk tree", + &out, + &filtered, + ); + Ok(0) +} + +#[allow(clippy::too_many_arguments)] +fn render_dir( + dir: &Path, + prefix: &str, + opts: &TreeOpts, + depth: usize, + out: &mut String, + dirs: &mut usize, + files: &mut usize, +) { + if let Some(max) = opts.max_depth { + if depth > max { + return; + } + } + + let mut entries: Vec<(String, bool)> = match std::fs::read_dir(dir) { + Ok(rd) => rd + .flatten() + .filter_map(|e| { + let name = e.file_name().to_string_lossy().to_string(); + let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false); + if !opts.show_all && name.starts_with('.') { + return None; + } + if !opts.show_all && is_dir && NOISE_DIRS.contains(&name.as_str()) { + return None; + } + if opts.dirs_only && !is_dir { + return None; + } + Some((name, is_dir)) + }) + .collect(), + Err(_) => return, + }; + + entries.sort(); + let last_idx = entries.len().saturating_sub(1); + + for (idx, (name, is_dir)) in entries.iter().enumerate() { + let is_last = idx == last_idx; + let connector = if is_last { "└── " } else { "├── " }; + out.push_str(prefix); + out.push_str(connector); + out.push_str(name); + out.push('\n'); + + if *is_dir { + *dirs += 1; + let child_prefix = format!("{prefix}{}", if is_last { " " } else { "│ " }); + render_dir( + &dir.join(name), + &child_prefix, + opts, + depth + 1, + out, + dirs, + files, + ); + } else { + *files += 1; + } + } +} + fn filter_tree_output(raw: &str) -> String { let lines: Vec<&str> = raw.lines().collect(); @@ -166,4 +321,37 @@ mod tests { assert!(NOISE_DIRS.contains(&"dist")); assert!(NOISE_DIRS.contains(&"build")); } + + #[test] + fn test_parse_tree_args_defaults() { + let opts = parse_tree_args(&[]); + assert!(!opts.show_all); + assert!(!opts.dirs_only); + assert_eq!(opts.max_depth, None); + assert_eq!(opts.root, "."); + } + + #[test] + fn test_parse_tree_args_flags_and_path() { + let args: Vec = vec!["-a".into(), "-L".into(), "2".into(), "src".into()]; + let opts = parse_tree_args(&args); + assert!(opts.show_all); + assert_eq!(opts.max_depth, Some(2)); + assert_eq!(opts.root, "src"); + } + + #[test] + fn test_parse_tree_args_dirs_only_and_inline_depth() { + let args: Vec = vec!["-d".into(), "-L3".into()]; + let opts = parse_tree_args(&args); + assert!(opts.dirs_only); + assert_eq!(opts.max_depth, Some(3)); + } + + #[test] + fn test_parse_tree_args_skips_ignore_pattern() { + let args: Vec = vec!["-I".into(), "node_modules".into(), "mydir".into()]; + let opts = parse_tree_args(&args); + assert_eq!(opts.root, "mydir"); + } } diff --git a/src/cmds/system/wc_cmd.rs b/src/cmds/system/wc_cmd.rs index 78c3924411..9b3986f68f 100644 --- a/src/cmds/system/wc_cmd.rs +++ b/src/cmds/system/wc_cmd.rs @@ -7,10 +7,20 @@ /// - `wc -c file.py` → `978` /// - `wc -l *.py` → table with common path prefix stripped use crate::core::runner::{self, RunOptions}; -use crate::core::utils::resolved_command; +use crate::core::tracking::TimedExecution; +use crate::core::utils::{resolved_command, tool_exists}; use anyhow::Result; +use std::io::Read; pub fn run(args: &[String], verbose: u8) -> Result { + let mode = detect_mode(args); + + // On Windows (and any host lacking the Unix `wc` binary) fall back to a + // native Rust implementation so `rtk wc` works without coreutils installed. + if !tool_exists("wc") { + return run_native(args, &mode, verbose); + } + let mut cmd = resolved_command("wc"); for arg in args { cmd.arg(arg); @@ -20,8 +30,6 @@ pub fn run(args: &[String], verbose: u8) -> Result { eprintln!("Running: wc {}", args.join(" ")); } - let mode = detect_mode(args); - // No file operands → wc reads from stdin. Forward rtk's stdin to the child // so `cat file | rtk wc` counts the piped data instead of reporting zero. let reads_stdin = !args.iter().any(|a| !a.starts_with('-')); @@ -40,6 +48,172 @@ pub fn run(args: &[String], verbose: u8) -> Result { ) } +/// Byte/word/line/char counts for a chunk of input. +struct Counts { + lines: u64, + words: u64, + bytes: u64, + chars: u64, +} + +impl Counts { + fn from_bytes(data: &[u8]) -> Self { + let bytes = data.len() as u64; + let lines = data.iter().filter(|&&b| b == b'\n').count() as u64; + let text = String::from_utf8_lossy(data); + let words = text.split_whitespace().count() as u64; + let chars = text.chars().count() as u64; + Counts { + lines, + words, + bytes, + chars, + } + } + + fn add(&mut self, other: &Counts) { + self.lines += other.lines; + self.words += other.words; + self.bytes += other.bytes; + self.chars += other.chars; + } +} + +/// Which numeric columns to emit, in `wc`'s fixed order (lines, words, chars, bytes). +struct WcCols { + lines: bool, + words: bool, + chars: bool, + bytes: bool, +} + +impl WcCols { + fn from_args(args: &[String]) -> Self { + let mut l = false; + let mut w = false; + let mut c = false; + let mut m = false; + let mut any = false; + for flag in args.iter().filter(|a| a.starts_with('-')) { + for ch in flag.chars().skip(1) { + match ch { + 'l' => { + l = true; + any = true; + } + 'w' => { + w = true; + any = true; + } + 'c' => { + c = true; + any = true; + } + 'm' => { + m = true; + any = true; + } + _ => {} + } + } + } + // No selecting flag → default columns: lines, words, bytes. + if !any { + return WcCols { + lines: true, + words: true, + chars: false, + bytes: true, + }; + } + WcCols { + lines: l, + words: w, + chars: m, + bytes: c, + } + } + + /// Render the requested counts as a space-separated number string. + fn render(&self, c: &Counts) -> String { + let mut nums: Vec = Vec::new(); + if self.lines { + nums.push(c.lines.to_string()); + } + if self.words { + nums.push(c.words.to_string()); + } + if self.chars { + nums.push(c.chars.to_string()); + } + if self.bytes { + nums.push(c.bytes.to_string()); + } + nums.join(" ") + } +} + +/// Native `wc` implementation used when the Unix binary is unavailable +/// (e.g. on stock Windows). Produces output in the same shape the spawn +/// path emits, then reuses [`filter_wc_output`]. +fn run_native(args: &[String], mode: &WcMode, verbose: u8) -> Result { + let timer = TimedExecution::start(); + let cols = WcCols::from_args(args); + + let files: Vec<&String> = args.iter().filter(|a| !a.starts_with('-')).collect(); + + let mut raw = String::new(); + let mut exit_code = 0; + + if files.is_empty() { + // No file operands → read from stdin. + let mut buf = Vec::new(); + std::io::stdin() + .read_to_end(&mut buf) + .map_err(|e| anyhow::anyhow!("wc: failed to read stdin: {e}"))?; + let counts = Counts::from_bytes(&buf); + raw.push_str(&cols.render(&counts)); + raw.push('\n'); + } else { + let mut total = Counts { + lines: 0, + words: 0, + bytes: 0, + chars: 0, + }; + for file in &files { + match std::fs::read(file) { + Ok(data) => { + let counts = Counts::from_bytes(&data); + total.add(&counts); + raw.push_str(&cols.render(&counts)); + raw.push(' '); + raw.push_str(file); + raw.push('\n'); + } + Err(e) => { + eprintln!("wc: {file}: {e}"); + exit_code = 1; + } + } + } + if files.len() > 1 { + raw.push_str(&cols.render(&total)); + raw.push_str(" total\n"); + } + } + + let filtered = filter_wc_output(&raw, mode); + + if verbose > 0 { + eprintln!("wc (native): {} files", files.len()); + } + + println!("{filtered}"); + timer.track(&format!("wc {}", args.join(" ")), "rtk wc", &raw, &filtered); + Ok(exit_code) +} + /// Which columns the user requested #[derive(Debug, PartialEq)] enum WcMode { @@ -385,4 +559,58 @@ mod tests { let result = filter_wc_output(raw, &WcMode::Full); assert_eq!(result, ""); } + + #[test] + fn test_counts_from_bytes() { + let c = Counts::from_bytes(b"hello world\nsecond line\n"); + assert_eq!(c.lines, 2); + assert_eq!(c.words, 4); + assert_eq!(c.bytes, 24); + assert_eq!(c.chars, 24); + } + + #[test] + fn test_counts_no_trailing_newline() { + let c = Counts::from_bytes(b"one two three"); + assert_eq!(c.lines, 0); + assert_eq!(c.words, 3); + assert_eq!(c.bytes, 13); + } + + #[test] + fn test_wccols_default_full() { + let cols = WcCols::from_args(&["file.txt".into()]); + let c = Counts { + lines: 10, + words: 20, + bytes: 100, + chars: 95, + }; + // Default → lines words bytes (no chars). + assert_eq!(cols.render(&c), "10 20 100"); + } + + #[test] + fn test_wccols_lines_only() { + let cols = WcCols::from_args(&["-l".into(), "f".into()]); + let c = Counts { + lines: 42, + words: 0, + bytes: 0, + chars: 0, + }; + assert_eq!(cols.render(&c), "42"); + } + + #[test] + fn test_wccols_chars_with_m() { + let cols = WcCols::from_args(&["-m".into(), "f".into()]); + let c = Counts { + lines: 0, + words: 0, + bytes: 100, + chars: 95, + }; + assert_eq!(cols.render(&c), "95"); + } } From 066f839e5f8768b6473628b0fb2bb0975c7b9287 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A0=20Vi=E1=BB=87t=20Ti=E1=BA=BFn?= <34891363+tienthegainz@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:10:56 +0700 Subject: [PATCH 2/7] Remove unnecessary copilot instruction --- .github/copilot-instructions.md | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 3fc45705ca..e2681b6beb 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -64,31 +64,3 @@ For full details see [ARCHITECTURE.md](../docs/contributing/ARCHITECTURE.md) and - **Branch naming**: `fix(scope):`, `feat(scope):`, `chore(scope):` where scope is the affected component. For the full contribution workflow, design philosophy, and new-filter checklist, see [CONTRIBUTING.md](../CONTRIBUTING.md). - - -# RTK — Token-Optimized CLI - -**rtk** is a CLI proxy that filters and compresses command outputs, saving 60-90% tokens. - -## Rule - -Always prefix shell commands with `rtk`: - -```bash -# Instead of: Use: -git status rtk git status -git log -10 rtk git log -10 -cargo test rtk cargo test -docker ps rtk docker ps -kubectl get pods rtk kubectl pods -``` - -## Meta commands (use directly) - -```bash -rtk gain # Token savings dashboard -rtk gain --history # Per-command savings history -rtk discover # Find missed rtk opportunities -rtk proxy # Run raw (no filtering) but track usage -``` - \ No newline at end of file From cb0ab9b5f535f0a6f5623be6d28809e569f00808 Mon Sep 17 00:00:00 2001 From: Luca-it15 Date: Wed, 29 Jul 2026 19:17:48 +0200 Subject: [PATCH 3/7] fix(build): support non-MSVC Windows linkers --- build.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/build.rs b/build.rs index 1d4ae93d1b..c725418f54 100644 --- a/build.rs +++ b/build.rs @@ -3,13 +3,16 @@ use std::fs; use std::path::Path; fn main() { - #[cfg(windows)] - { + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") { // Clap + the full command graph can exceed the default 1 MiB Windows // main-thread stack during process startup. Reserve a larger stack for // the CLI binary so `rtk.exe --version`, `--help`, and hook entry // points start reliably without requiring ad-hoc RUSTFLAGS. - println!("cargo:rustc-link-arg=/STACK:8388608"); + let stack_arg = match std::env::var("CARGO_CFG_TARGET_ENV").as_deref() { + Ok("msvc") => "/STACK:8388608", + _ => "-Wl,--stack,8388608", + }; + println!("cargo:rustc-link-arg={stack_arg}"); } let filters_dir = Path::new("src/filters"); From d1a7e8592d0a64b2beb044a1e24981d634b686b7 Mon Sep 17 00:00:00 2001 From: Luca-it15 Date: Wed, 29 Jul 2026 19:17:49 +0200 Subject: [PATCH 4/7] feat(system): add PowerShell command routing --- src/cmds/system/README.md | 11 +- src/cmds/system/powershell_cmd.rs | 541 ++++++++++++++++++++++++++++++ src/discover/registry.rs | 59 ++++ src/discover/rules.rs | 14 + src/main.rs | 41 ++- 5 files changed, 661 insertions(+), 5 deletions(-) create mode 100644 src/cmds/system/powershell_cmd.rs diff --git a/src/cmds/system/README.md b/src/cmds/system/README.md index 3e34e126fb..77487afea3 100644 --- a/src/cmds/system/README.md +++ b/src/cmds/system/README.md @@ -24,7 +24,7 @@ used when the underlying binary is unavailable (detected via `filter_wc_output`. - `tree.rs` — `run_native` does a recursive `std::fs` walk, pruning `NOISE_DIRS` unless `-a`. -- `grep_cmd.rs` — `native_grep` (final fallback after `rg` then `grep`) walks +- `search.rs` — `native_grep` (final fallback after `rg` then `grep`) walks files with the `ignore` crate and matches with `regex`, emitting the same NUL-separated `path\0line:content` format the parser expects. It respects `.gitignore` and skips hidden files, which differs slightly from @@ -35,8 +35,8 @@ behavior is identical to the Unix spawn path. ## PowerShell cmdlet rewrites -`discover/registry.rs::try_rewrite_powershell` maps common, **non-piped** -PowerShell cmdlets to their rtk equivalents so the hook saves tokens on Windows: +`powershell_cmd.rs` maps common, **non-piped** PowerShell cmdlets to their rtk +equivalents so the hook saves tokens on Windows: | Cmdlet (and aliases) | rtk equivalent | |---|---| @@ -47,6 +47,11 @@ PowerShell cmdlets to their rtk equivalents so the hook saves tokens on Windows: Piped/compound cmdlet invocations are intentionally left untouched to avoid breaking PowerShell pipeline semantics. +The `rtk powershell` and `rtk pwsh` wrappers also recognize safe `-Command` +forms and dispatch those same cmdlets through RTK. Scripts, `-File`, +`-EncodedCommand`, pipelines, interpolation, and unsupported parameters run +unchanged through the requested PowerShell executable. + ## Cross-command - `format_cmd` routes to `cmds/js/prettier_cmd` and `cmds/python/ruff_cmd` diff --git a/src/cmds/system/powershell_cmd.rs b/src/cmds/system/powershell_cmd.rs new file mode 100644 index 0000000000..eaac142cd6 --- /dev/null +++ b/src/cmds/system/powershell_cmd.rs @@ -0,0 +1,541 @@ +use crate::core::tracking::TimedExecution; +use crate::core::utils::{exit_code_from_status, resolved_command}; +use anyhow::{Context, Result}; +use std::process::Command; + +#[derive(Debug, Clone, PartialEq, Eq)] +struct PsToken { + value: String, + raw: String, +} + +impl PsToken { + fn as_rtk_arg(&self) -> RtkArg { + RtkArg { + value: self.value.clone(), + display: self.raw.clone(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct RtkArg { + value: String, + display: String, +} + +impl RtkArg { + fn literal(value: &str) -> Self { + Self { + value: value.to_string(), + display: value.to_string(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct RtkRewrite { + args: Vec, +} + +impl RtkRewrite { + fn command_line(&self) -> String { + format!( + "rtk {}", + self.args + .iter() + .map(|arg| arg.display.as_str()) + .collect::>() + .join(" ") + ) + } + + fn process_args(&self) -> impl Iterator { + self.args.iter().map(|arg| arg.value.as_str()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Quote { + Single, + Double, +} + +/// Return a hook-safe RTK replacement for a PowerShell executable or a +/// supported bare cmdlet. Unsupported PowerShell syntax returns `None`. +pub fn rewrite_for_hook(command: &str) -> Option { + rewrite_shell_wrapper(command).or_else(|| rewrite_cmdlet(command).map(|r| r.command_line())) +} + +/// Execute `powershell`/`pwsh`, dispatching a safe `-Command` cmdlet through +/// RTK and preserving all other invocations as transparent passthrough. +pub fn run(shell: &str, args: &[String], verbose: u8) -> Result { + if let Some(script) = extract_rewritable_script(args) { + if let Some(rewrite) = rewrite_cmdlet(&script) { + if verbose > 0 { + eprintln!("PowerShell rewrite: {}", rewrite.command_line()); + } + let current_exe = + std::env::current_exe().context("Failed to locate the current rtk executable")?; + let status = Command::new(current_exe) + .args(rewrite.process_args()) + .status() + .context("Failed to execute rewritten PowerShell command")?; + return Ok(exit_code_from_status(&status, "PowerShell rewrite")); + } + } + + let timer = TimedExecution::start(); + let status = resolved_command(shell) + .args(args) + .status() + .with_context(|| format!("Failed to execute {shell}"))?; + let raw = format_command(shell, args); + timer.track_passthrough(&raw, &format!("rtk {raw} (passthrough)")); + Ok(exit_code_from_status(&status, shell)) +} + +fn format_command(shell: &str, args: &[String]) -> String { + if args.is_empty() { + shell.to_string() + } else { + format!("{shell} {}", args.join(" ")) + } +} + +fn extract_rewritable_script(args: &[String]) -> Option { + let mut index = 0; + while index < args.len() { + match args[index].to_ascii_lowercase().as_str() { + "-noprofile" | "-noninteractive" | "-nologo" => index += 1, + "-command" | "-c" => { + let script = args.get(index + 1..)?.join(" "); + return (!script.trim().is_empty()).then_some(script); + } + _ => return None, + } + } + None +} + +fn rewrite_shell_wrapper(command: &str) -> Option { + let trimmed = command.trim(); + let (head, head_end) = first_token(trimmed)?; + let executable = head + .rsplit(['/', '\\']) + .next() + .unwrap_or(head.as_str()) + .to_ascii_lowercase(); + let canonical = match executable.as_str() { + "powershell" | "powershell.exe" => "powershell", + "pwsh" | "pwsh.exe" => "pwsh", + _ => return None, + }; + Some(format!("rtk {canonical}{}", &trimmed[head_end..])) +} + +fn first_token(input: &str) -> Option<(String, usize)> { + let mut value = String::new(); + let mut quote = None; + let mut escaped = false; + + for (offset, ch) in input.char_indices() { + if escaped { + value.push(ch); + escaped = false; + continue; + } + match quote { + Some(Quote::Single) => { + if ch == '\'' { + quote = None; + } else { + value.push(ch); + } + } + Some(Quote::Double) => match ch { + '"' => quote = None, + '`' => escaped = true, + _ => value.push(ch), + }, + None => match ch { + '\'' => quote = Some(Quote::Single), + '"' => quote = Some(Quote::Double), + '`' => escaped = true, + c if c.is_whitespace() => return (!value.is_empty()).then_some((value, offset)), + _ => value.push(ch), + }, + } + } + + if quote.is_none() && !escaped && !value.is_empty() { + Some((value, input.len())) + } else { + None + } +} + +fn rewrite_cmdlet(command: &str) -> Option { + let tokens = ps_split(command)?; + let (head, args) = tokens.split_first()?; + match head.value.to_ascii_lowercase().as_str() { + "get-content" | "gc" | "type" => rewrite_get_content(args), + "get-childitem" | "gci" | "dir" | "ls" => rewrite_get_child_item(args), + "select-string" | "sls" => rewrite_select_string(args), + _ => None, + } +} + +fn rewrite_get_content(tokens: &[PsToken]) -> Option { + let mut paths = Vec::new(); + let mut max_lines = None; + let mut tail_lines = None; + let mut index = 0; + + while index < tokens.len() { + let lower = tokens[index].value.to_ascii_lowercase(); + match lower.as_str() { + "-path" | "-literalpath" => { + let path = tokens.get(index + 1)?; + if has_wildcard(&path.value) { + return None; + } + paths.push(path.as_rtk_arg()); + index += 2; + } + "-totalcount" => { + let count = tokens.get(index + 1)?; + count.value.parse::().ok()?; + max_lines = Some(count.as_rtk_arg()); + index += 2; + } + "-tail" => { + let count = tokens.get(index + 1)?; + count.value.parse::().ok()?; + tail_lines = Some(count.as_rtk_arg()); + index += 2; + } + _ if lower.starts_with('-') => return None, + _ => { + if has_wildcard(&tokens[index].value) { + return None; + } + paths.push(tokens[index].as_rtk_arg()); + index += 1; + } + } + } + + if paths.is_empty() || (max_lines.is_some() && tail_lines.is_some()) { + return None; + } + + let mut args = vec![RtkArg::literal("read")]; + args.extend(paths); + if let Some(count) = max_lines { + args.push(RtkArg::literal("--max-lines")); + args.push(count); + } + if let Some(count) = tail_lines { + args.push(RtkArg::literal("--tail-lines")); + args.push(count); + } + Some(RtkRewrite { args }) +} + +fn rewrite_get_child_item(tokens: &[PsToken]) -> Option { + let mut paths = Vec::new(); + let mut recurse = false; + let mut force = false; + let mut depth = None; + let mut index = 0; + + while index < tokens.len() { + let lower = tokens[index].value.to_ascii_lowercase(); + match lower.as_str() { + "-path" | "-literalpath" => { + let path = tokens.get(index + 1)?; + if has_wildcard(&path.value) { + return None; + } + paths.push(path.as_rtk_arg()); + index += 2; + } + "-recurse" => { + recurse = true; + index += 1; + } + "-force" => { + force = true; + index += 1; + } + "-depth" => { + let value = tokens.get(index + 1)?; + value.value.parse::().ok()?; + depth = Some(value.as_rtk_arg()); + recurse = true; + index += 2; + } + _ if lower.starts_with('-') => return None, + _ => { + if has_wildcard(&tokens[index].value) { + return None; + } + paths.push(tokens[index].as_rtk_arg()); + index += 1; + } + } + } + + let mut args = vec![RtkArg::literal(if recurse { "tree" } else { "ls" })]; + if force { + args.push(RtkArg::literal("-a")); + } + if let Some(value) = depth { + args.push(RtkArg::literal("-L")); + args.push(value); + } + args.extend(paths); + Some(RtkRewrite { args }) +} + +fn rewrite_select_string(tokens: &[PsToken]) -> Option { + let mut pattern = None; + let mut paths = Vec::new(); + let mut case_sensitive = false; + let mut invert_match = false; + let mut index = 0; + + while index < tokens.len() { + let lower = tokens[index].value.to_ascii_lowercase(); + match lower.as_str() { + "-pattern" => { + let value = tokens.get(index + 1)?; + pattern = Some(value.as_rtk_arg()); + index += 2; + } + "-path" | "-literalpath" => { + let value = tokens.get(index + 1)?; + if has_wildcard(&value.value) { + return None; + } + paths.push(value.as_rtk_arg()); + index += 2; + } + "-casesensitive" => { + case_sensitive = true; + index += 1; + } + "-notmatch" => { + invert_match = true; + index += 1; + } + _ if lower.starts_with('-') => return None, + _ if pattern.is_none() => { + pattern = Some(tokens[index].as_rtk_arg()); + index += 1; + } + _ => { + if has_wildcard(&tokens[index].value) { + return None; + } + paths.push(tokens[index].as_rtk_arg()); + index += 1; + } + } + } + + let pattern = pattern?; + if paths.is_empty() { + return None; + } + + let mut args = vec![RtkArg::literal("grep")]; + if !case_sensitive { + args.push(RtkArg::literal("-i")); + } + if invert_match { + args.push(RtkArg::literal("-v")); + } + args.push(pattern); + args.extend(paths); + Some(RtkRewrite { args }) +} + +fn has_wildcard(value: &str) -> bool { + value.contains(['*', '?']) +} + +/// PowerShell-aware tokenization for the conservative cmdlet subset above. +/// Backslashes remain literal; backticks escape the next character; compound +/// syntax and dynamic interpolation are rejected. +fn ps_split(input: &str) -> Option> { + let chars: Vec<(usize, char)> = input.char_indices().collect(); + let mut tokens = Vec::new(); + let mut token_start = None; + let mut value = String::new(); + let mut quote = None; + let mut index = 0; + + while index < chars.len() { + let (offset, ch) = chars[index]; + match quote { + Some(Quote::Single) => { + if ch == '\'' { + if chars.get(index + 1).is_some_and(|(_, next)| *next == '\'') { + value.push('\''); + index += 2; + continue; + } + quote = None; + } else { + value.push(ch); + } + } + Some(Quote::Double) => match ch { + '"' => quote = None, + '`' => { + let (_, escaped) = chars.get(index + 1)?; + value.push(*escaped); + index += 2; + continue; + } + '$' => return None, + _ => value.push(ch), + }, + None => match ch { + c if c.is_whitespace() => { + if let Some(start) = token_start.take() { + tokens.push(PsToken { + value: std::mem::take(&mut value), + raw: input[start..offset].to_string(), + }); + } + } + '\'' => { + token_start.get_or_insert(offset); + quote = Some(Quote::Single); + } + '"' => { + token_start.get_or_insert(offset); + quote = Some(Quote::Double); + } + '`' => { + token_start.get_or_insert(offset); + let (_, escaped) = chars.get(index + 1)?; + value.push(*escaped); + index += 2; + continue; + } + '|' | ';' | '&' | '<' | '>' | '{' | '}' | '(' | ')' | ',' | '$' => return None, + '#' if token_start.is_none() => return None, + _ => { + token_start.get_or_insert(offset); + value.push(ch); + } + }, + } + index += 1; + } + + if quote.is_some() { + return None; + } + if let Some(start) = token_start { + tokens.push(PsToken { + value, + raw: input[start..].to_string(), + }); + } + Some(tokens) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn powershell_split_preserves_windows_paths_and_raw_quotes() { + let tokens = ps_split(r#"Select-String -Pattern "fn run" -Path src\main.rs"#).unwrap(); + assert_eq!(tokens[2].value, "fn run"); + assert_eq!(tokens[2].raw, r#""fn run""#); + assert_eq!(tokens[4].value, r"src\main.rs"); + } + + #[test] + fn powershell_split_rejects_dynamic_or_compound_scripts() { + for command in [ + "Get-ChildItem | Where-Object Length", + "Get-Content $env:TEMP", + "Get-Content a; Remove-Item a", + "Get-Content (Join-Path a b)", + ] { + assert!(ps_split(command).is_none(), "{command}"); + } + } + + #[test] + fn hook_rewrite_preserves_wrapper_arguments() { + assert_eq!( + rewrite_for_hook(r#"PowerShell.exe -NoProfile -Command "Get-ChildItem src""#).as_deref(), + Some(r#"rtk powershell -NoProfile -Command "Get-ChildItem src""#) + ); + assert_eq!( + rewrite_for_hook(r#""C:\Program Files\PowerShell\7\pwsh.exe" -File script.ps1"#) + .as_deref(), + Some("rtk pwsh -File script.ps1") + ); + } + + #[test] + fn extracts_only_safe_command_invocations() { + assert_eq!( + extract_rewritable_script(&[ + "-NoProfile".to_string(), + "-Command".to_string(), + "Get-ChildItem src".to_string(), + ]) + .as_deref(), + Some("Get-ChildItem src") + ); + assert!(extract_rewritable_script(&[ + "-WorkingDirectory".to_string(), + "C:\\".to_string(), + "-Command".to_string(), + "Get-ChildItem".to_string(), + ]) + .is_none()); + assert!(extract_rewritable_script(&[ + "-EncodedCommand".to_string(), + "RwBlAHQALQBEAGEAdABlAA==".to_string(), + ]) + .is_none()); + } + + #[test] + fn cmdlet_rewrites_cover_content_listing_and_search() { + assert_eq!( + rewrite_for_hook("Get-Content README.md -Tail 20").as_deref(), + Some("rtk read README.md --tail-lines 20") + ); + assert_eq!( + rewrite_for_hook("Get-ChildItem -Depth 2 -Force src").as_deref(), + Some("rtk tree -a -L 2 src") + ); + assert_eq!( + rewrite_for_hook(r#"Select-String -Pattern "fn run" -Path src\main.rs"#).as_deref(), + Some(r#"rtk grep -i "fn run" src\main.rs"#) + ); + } + + #[test] + fn unsupported_cmdlet_options_do_not_rewrite() { + for command in [ + "Get-Content -Raw README.md", + "Get-ChildItem -Filter *.rs", + "Select-String -Context 2,2 TODO src", + ] { + assert_eq!(rewrite_for_hook(command), None, "{command}"); + } + } +} diff --git a/src/discover/registry.rs b/src/discover/registry.rs index 81b170785a..903059382b 100644 --- a/src/discover/registry.rs +++ b/src/discover/registry.rs @@ -1017,6 +1017,15 @@ fn rewrite_segment_inner( return Some(trimmed.to_string()); } + if context == RewriteContext::Normal { + if let Some(rewritten) = crate::cmds::system::powershell_cmd::rewrite_for_hook(cmd_part) { + if is_excluded(cmd_part, excluded) { + return None; + } + return Some(format!("{rewritten}{redirect_suffix}")); + } + } + if context == RewriteContext::Normal && (cmd_part.starts_with("head -") || cmd_part.starts_with("tail ")) { @@ -1239,6 +1248,56 @@ mod tests { assert!(!search_uses_pattern_file("grep -F pattern")); } + #[test] + fn test_rewrite_powershell_file_cmdlets() { + assert_eq!( + rewrite_command_no_prefixes("Get-Content README.md", &[]), + Some("rtk read README.md".to_string()) + ); + assert_eq!( + rewrite_command_no_prefixes("Get-ChildItem -Recurse -Force src", &[]), + Some("rtk tree -a src".to_string()) + ); + assert_eq!( + rewrite_command_no_prefixes( + r#"Select-String -Pattern "fn run" -Path src\main.rs"#, + &[] + ), + Some(r#"rtk grep -i "fn run" src\main.rs"#.to_string()) + ); + } + + #[test] + fn test_rewrite_powershell_wrappers() { + assert_eq!( + rewrite_command_no_prefixes( + r#"powershell.exe -NoProfile -Command "Get-ChildItem src""#, + &[] + ), + Some(r#"rtk powershell -NoProfile -Command "Get-ChildItem src""#.to_string()) + ); + assert_eq!( + rewrite_command_no_prefixes(r#"pwsh -Command "Get-Content README.md""#, &[]), + Some(r#"rtk pwsh -Command "Get-Content README.md""#.to_string()) + ); + } + + #[test] + fn test_rewrite_powershell_unsafe_forms_pass_through() { + assert_eq!( + rewrite_command_no_prefixes("Get-ChildItem | Where-Object Length -gt 0", &[]), + None + ); + assert_eq!( + rewrite_command_no_prefixes("Get-Content -Raw README.md", &[]), + None + ); + assert_eq!( + rewrite_command_no_prefixes("Select-String -Context 2,2 TODO src", &[]), + None + ); + } + #[test] fn test_classify_git_status() { assert_eq!( diff --git a/src/discover/rules.rs b/src/discover/rules.rs index 49c0ff740a..d212d2a7cf 100644 --- a/src/discover/rules.rs +++ b/src/discover/rules.rs @@ -135,6 +135,20 @@ pub const RULES: &[RtkRule] = &[ savings_pct: 65.0, ..RtkRule::DEFAULT }, + RtkRule { + pattern: r"(?i)^powershell(?:\.exe)?(?:\s|$)", + rtk_cmd: "rtk powershell", + rewrite_prefixes: &["powershell.exe", "powershell"], + category: "System", + ..RtkRule::DEFAULT + }, + RtkRule { + pattern: r"(?i)^pwsh(?:\.exe)?(?:\s|$)", + rtk_cmd: "rtk pwsh", + rewrite_prefixes: &["pwsh.exe", "pwsh"], + category: "System", + ..RtkRule::DEFAULT + }, RtkRule { pattern: r"^find\s+", rtk_cmd: "rtk find", diff --git a/src/main.rs b/src/main.rs index d1e0269f5a..f306c33dfe 100644 --- a/src/main.rs +++ b/src/main.rs @@ -22,8 +22,8 @@ use cmds::ruby::{rake_cmd, rspec_cmd, rubocop_cmd}; use cmds::rust::{cargo_cmd, runner}; use cmds::scala::sbt_cmd; use cmds::system::{ - deps, env_cmd, find_cmd, format_cmd, json_cmd, local_llm, log_cmd, ls, pipe_cmd, read, search, - summary, tree, wc_cmd, + deps, env_cmd, find_cmd, format_cmd, json_cmd, local_llm, log_cmd, ls, pipe_cmd, + powershell_cmd, read, search, summary, tree, wc_cmd, }; use anyhow::{Context, Result}; @@ -335,6 +335,22 @@ enum Commands { extra_args: Vec, }, + /// Windows PowerShell with safe cmdlet routing and transparent fallback + #[command(name = "powershell", disable_help_flag = true)] + PowerShell { + /// Arguments passed to powershell.exe + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + + /// PowerShell 7+ with safe cmdlet routing and transparent fallback + #[command(name = "pwsh", disable_help_flag = true)] + Pwsh { + /// Arguments passed to pwsh + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Initialize rtk instructions for assistant CLI usage Init { /// Add to global assistant config directory instead of local project file @@ -1991,6 +2007,10 @@ fn run_cli() -> Result { search::run(search::Engine::Rg, 80, 200, false, &extra_args, cli.verbose)? } + Commands::PowerShell { args } => powershell_cmd::run("powershell", &args, cli.verbose)?, + + Commands::Pwsh { args } => powershell_cmd::run("pwsh", &args, cli.verbose)?, + Commands::Init { global, opencode, @@ -2737,6 +2757,8 @@ fn is_operational_command(cmd: &Commands) -> bool { | Commands::Summary { .. } | Commands::Grep { .. } | Commands::Rg { .. } + | Commands::PowerShell { .. } + | Commands::Pwsh { .. } | Commands::Wget { .. } | Commands::Vitest { .. } | Commands::Prisma { .. } @@ -3099,6 +3121,8 @@ mod tests { "tree", "read", "rg", + "powershell", + "pwsh", "git", "gh", "glab", @@ -3196,6 +3220,19 @@ mod tests { } } + #[test] + fn test_powershell_commands_parse_trailing_args() { + assert!(Cli::try_parse_from([ + "rtk", + "powershell", + "-NoProfile", + "-Command", + "Get-ChildItem src" + ]) + .is_ok()); + assert!(Cli::try_parse_from(["rtk", "pwsh", "-Command", "Get-Content README.md"]).is_ok()); + } + #[test] fn test_hook_claude_parses() { let cli = Cli::try_parse_from(["rtk", "hook", "claude"]).unwrap(); From c21b8b816da71307eaa93d98f4f77ee83476ebdd Mon Sep 17 00:00:00 2001 From: sreeram Anikode Mahadevan Date: Tue, 7 Jul 2026 15:49:42 -0500 Subject: [PATCH 5/7] feat(search): add pure-Rust fallback for grep/rg when binary is missing on PATH MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plain Windows PowerShell (no Git Bash/WSL, no ripgrep installed) has neither grep nor rg on PATH, so `rtk grep`/`rtk rg` hard-failed instead of running. engine_capture now checks tool_exists(engine.bin()) and, only when the requested binary is absent, walks paths with the already-vendored `ignore` crate and matches lines with the already-vendored `regex` crate. This never substitutes one engine for the other — grep still only falls back when grep itself is missing, same for rg. Output is emitted in the exact file\0line:content shape parse_match_line already expects, so grouping/ truncation/display code is untouched. Context lines (-A/-B/-C) are not supported in fallback mode; only -i/--ignore-case is honored from extra_args. Forward-ports unreleased fork-local work (originally against the pre-refactor grep_cmd.rs) onto develop's search.rs/Engine design. Not tied to a GitHub issue; staged for maintainer discussion before submission. --- src/cmds/system/search.rs | 170 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 164 insertions(+), 6 deletions(-) diff --git a/src/cmds/system/search.rs b/src/cmds/system/search.rs index b526c05e12..aac3d2d245 100644 --- a/src/cmds/system/search.rs +++ b/src/cmds/system/search.rs @@ -10,10 +10,10 @@ use crate::core::stream::{ self, exec_capture, exec_capture_stdin, CaptureResult, FilterMode, StdinMode, StreamFilter, }; use crate::core::tracking; -use crate::core::utils::{resolved_command, strip_ansi}; +use crate::core::utils::{resolved_command, strip_ansi, tool_exists}; use crate::core::{args_utils, config}; use anyhow::{Context, Result}; -use regex::Regex; +use regex::{Regex, RegexBuilder}; use std::collections::HashMap; use std::io::IsTerminal; use std::process::Command; @@ -296,12 +296,20 @@ impl Engine { /// Runs the agent's exact engine + flags for the grouping path, appending only the /// parse aids (see `Engine::parse_flags`). +/// +/// Falls back to a pure-Rust walk+match (`fallback_capture`) when the requested +/// engine's binary isn't on PATH at all (plain Windows PowerShell with no +/// rg/grep/Git Bash, see #Windows-no-binary). This never substitutes grep for rg +/// or vice versa — it only engages when the *requested* binary is missing. fn engine_capture>( engine: Engine, extra_args: &[T], patterns: &[String], paths: &[String], ) -> Result { + if !tool_exists(engine.bin()) { + return fallback_capture(extra_args, patterns, paths); + } let mut cmd = engine_command(engine, extra_args, patterns, paths, false); exec_capture_stdin(&mut cmd).context("search failed") } @@ -437,6 +445,90 @@ fn run_streaming_search( Ok(result.exit_code) } +/// Pure-Rust walk+match used only when the requested engine's binary is missing +/// from PATH entirely. Emits the exact `file\0line_number:content` shape +/// `parse_match_line` expects (always the match separator `:`, never `-`), so +/// the rest of `run()`'s grouping/truncation/display logic is untouched. +/// +/// Degraded relative to a real engine: no -A/-B/-C context-line support +/// (context lines are only extra non-match lines shown around a hit, so +/// skipping them is a completeness gap, not a correctness bug — add if +/// requested). Only `-i`/`--ignore-case` is honored from `extra_args`; other +/// flags (glob/type filters, max-count, etc.) are ignored in this mode. +fn fallback_capture>( + extra_args: &[T], + patterns: &[String], + paths: &[String], +) -> Result { + let case_insensitive = + has_short_flag(extra_args, 'i') || extra_args.iter().any(|f| f.as_ref() == "--ignore-case"); + + // Multiple -e/patterns are OR'd together, same semantics as the real + // engines' multiple -e flags. + let combined = patterns + .iter() + .map(|p| format!("(?:{})", p)) + .collect::>() + .join("|"); + let re = RegexBuilder::new(&combined) + .case_insensitive(case_insensitive) + .build() + .with_context(|| format!("Invalid pattern for fallback search: {}", combined))?; + + let search_paths: Vec = if paths.is_empty() { + vec![".".to_string()] + } else { + paths.to_vec() + }; + + let mut stdout = String::new(); + for root in &search_paths { + // Mirrors find_cmd.rs: respect .gitignore/global/local excludes so + // fallback results line up with what rg's default already does. + let walker = ignore::WalkBuilder::new(root) + .git_ignore(true) + .git_global(true) + .git_exclude(true) + .build(); + + for entry in walker { + let entry = match entry { + Ok(e) => e, + Err(_) => continue, + }; + if entry.file_type().is_some_and(|t| !t.is_file()) { + continue; + } + + let path = entry.path(); + // Unreadable/non-UTF8 (likely binary) files: skip silently, same + // spirit as the real engines' -I binary-file skip. + let Ok(contents) = std::fs::read_to_string(path) else { + continue; + }; + let display = path.to_string_lossy(); + + for (idx, line) in contents.lines().enumerate() { + if re.is_match(line) { + stdout.push_str(&display); + stdout.push('\0'); + stdout.push_str(&(idx + 1).to_string()); + stdout.push(':'); + stdout.push_str(line); + stdout.push('\n'); + } + } + } + } + + let exit_code = if stdout.is_empty() { 1 } else { 0 }; + Ok(CaptureResult { + stdout, + stderr: String::new(), + exit_code, + }) +} + /// Runs the agent's command verbatim for forms RTK does not group: format/shape /// flags and pattern-less modes (`--files`, `--type-list`). fn passthrough>( @@ -472,10 +564,11 @@ fn passthrough>( Ok(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)) +fn has_short_flag>(flags: &[T], ch: char) -> bool { + flags.iter().any(|f| { + let f = f.as_ref(); + f.starts_with('-') && !f.starts_with("--") && f[1..].contains(ch) + }) } fn has_context_flag(flags: &[String]) -> bool { @@ -1639,6 +1732,71 @@ mod tests { assert!(!f(&["-i", "-w"])); } + // --- fallback_capture (Windows: no rg/grep on PATH) --- + + #[test] + fn test_fallback_capture_finds_matches_in_temp_dir() { + let tmp = tempfile::tempdir().expect("tempdir"); + std::fs::write(tmp.path().join("a.txt"), "hello world\nfoo bar\n").expect("write a.txt"); + std::fs::write(tmp.path().join("b.txt"), "nothing here\n").expect("write b.txt"); + + let patterns = vec!["hello".to_string()]; + let paths = vec![tmp.path().to_string_lossy().to_string()]; + let result = + fallback_capture::<&str>(&[], &patterns, &paths).expect("fallback_capture ok"); + + assert_eq!(result.exit_code, 0); + assert!(result.stdout.contains("a.txt")); + assert!(result.stdout.contains("hello world")); + assert!(!result.stdout.contains("nothing here")); + } + + #[test] + fn test_fallback_capture_case_insensitive() { + let tmp = tempfile::tempdir().expect("tempdir"); + std::fs::write(tmp.path().join("a.txt"), "HELLO world\n").expect("write a.txt"); + + let patterns = vec!["hello".to_string()]; + let paths = vec![tmp.path().to_string_lossy().to_string()]; + + // Without -i: no match. + let no_flag = + fallback_capture::<&str>(&[], &patterns, &paths).expect("fallback_capture ok"); + assert_eq!(no_flag.exit_code, 1); + assert!(no_flag.stdout.is_empty()); + + // With -i: matches regardless of case. + let with_flag = + fallback_capture(&["-i"], &patterns, &paths).expect("fallback_capture ok"); + assert_eq!(with_flag.exit_code, 0); + assert!(with_flag.stdout.contains("HELLO world")); + } + + #[test] + fn test_fallback_capture_output_round_trips_through_parse_match_line() { + // The seam that keeps this change minimal: fallback_capture's stdout + // must be fully consumable by the existing parse_match_line parser. + let tmp = tempfile::tempdir().expect("tempdir"); + std::fs::write(tmp.path().join("c.txt"), "line one\nmatch here\nline three\n") + .expect("write c.txt"); + + let patterns = vec!["match".to_string()]; + let paths = vec![tmp.path().to_string_lossy().to_string()]; + let result = + fallback_capture::<&str>(&[], &patterns, &paths).expect("fallback_capture ok"); + + assert!(!result.stdout.is_empty()); + for line in result.stdout.lines() { + let parsed = parse_match_line(line); + assert!(parsed.is_some(), "unparseable fallback line: {:?}", line); + let (file, line_num, is_match, content) = parsed.unwrap(); + assert!(file.ends_with("c.txt")); + assert_eq!(line_num, 2); + assert!(is_match); + assert_eq!(content, "match here"); + } + } + #[test] fn test_has_context_flag_long() { let f = |args: &[&str]| -> bool { From 6700939427385be2e0f0542cdfdfc1f21f82690e Mon Sep 17 00:00:00 2001 From: Luca-it15 Date: Wed, 29 Jul 2026 19:21:54 +0200 Subject: [PATCH 6/7] fix(search): honor invert match in native fallback --- src/cmds/system/search.rs | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/src/cmds/system/search.rs b/src/cmds/system/search.rs index aac3d2d245..5c9bcbe197 100644 --- a/src/cmds/system/search.rs +++ b/src/cmds/system/search.rs @@ -453,8 +453,9 @@ fn run_streaming_search( /// Degraded relative to a real engine: no -A/-B/-C context-line support /// (context lines are only extra non-match lines shown around a hit, so /// skipping them is a completeness gap, not a correctness bug — add if -/// requested). Only `-i`/`--ignore-case` is honored from `extra_args`; other -/// flags (glob/type filters, max-count, etc.) are ignored in this mode. +/// requested). `-i`/`--ignore-case` and `-v`/`--invert-match` are honored from +/// `extra_args`; other flags (glob/type filters, max-count, etc.) are ignored in +/// this mode. fn fallback_capture>( extra_args: &[T], patterns: &[String], @@ -462,6 +463,8 @@ fn fallback_capture>( ) -> Result { let case_insensitive = has_short_flag(extra_args, 'i') || extra_args.iter().any(|f| f.as_ref() == "--ignore-case"); + let invert_match = + has_short_flag(extra_args, 'v') || extra_args.iter().any(|f| f.as_ref() == "--invert-match"); // Multiple -e/patterns are OR'd together, same semantics as the real // engines' multiple -e flags. @@ -509,7 +512,7 @@ fn fallback_capture>( let display = path.to_string_lossy(); for (idx, line) in contents.lines().enumerate() { - if re.is_match(line) { + if re.is_match(line) != invert_match { stdout.push_str(&display); stdout.push('\0'); stdout.push_str(&(idx + 1).to_string()); @@ -1772,6 +1775,26 @@ mod tests { assert!(with_flag.stdout.contains("HELLO world")); } + #[test] + fn test_fallback_capture_invert_match() { + let tmp = tempfile::tempdir().expect("tempdir"); + std::fs::write( + tmp.path().join("a.txt"), + "matching line\nline selected by NotMatch\n", + ) + .expect("write a.txt"); + + let patterns = vec!["matching".to_string()]; + let paths = vec![tmp.path().to_string_lossy().to_string()]; + + let result = + fallback_capture(&["-v"], &patterns, &paths).expect("fallback_capture ok"); + + assert_eq!(result.exit_code, 0); + assert!(!result.stdout.contains("matching line")); + assert!(result.stdout.contains("line selected by NotMatch")); + } + #[test] fn test_fallback_capture_output_round_trips_through_parse_match_line() { // The seam that keeps this change minimal: fallback_capture's stdout From 310378e25e5b7a3f930e2636bfe519456ad17973 Mon Sep 17 00:00:00 2001 From: Luca-it15 Date: Wed, 29 Jul 2026 19:27:03 +0200 Subject: [PATCH 7/7] docs(system): clean up command reference --- src/cmds/system/README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/src/cmds/system/README.md b/src/cmds/system/README.md index 77487afea3..cb07fd5cc1 100644 --- a/src/cmds/system/README.md +++ b/src/cmds/system/README.md @@ -55,4 +55,3 @@ unchanged through the requested PowerShell executable. ## Cross-command - `format_cmd` routes to `cmds/js/prettier_cmd` and `cmds/python/ruff_cmd` -