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/README.md b/README.md index d91e21b246..1d81e2bb75 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 @@ -230,6 +230,18 @@ 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 +``` + +### 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 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/scripts/benchmark.sh b/scripts/benchmark.sh index 0af7e44179..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 @@ -248,7 +257,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 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/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/aws_cmd.rs b/src/cmds/cloud/aws_cmd.rs index 833d2fe7b9..fa76d73124 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) } @@ -410,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) } @@ -463,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) } diff --git a/src/cmds/cloud/container.rs b/src/cmds/cloud/container.rs index 2738cf29e0..348ab14f55 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; @@ -27,24 +28,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() } }, @@ -57,33 +58,34 @@ 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() { - rtk.push_str("[docker] 0 containers"); - println!("{}", rtk); - timer.track("docker ps", "rtk docker ps", &raw, &rtk); - return Ok(0); - } - const MAX_CONTAINERS: usize = CAP_LIST; let lines: Vec = stdout .lines() @@ -103,35 +105,45 @@ 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) } 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"); @@ -180,8 +192,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) } @@ -217,34 +230,35 @@ 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(); - if lines.is_empty() { - rtk.push_str("[docker] 0 images"); - println!("{}", rtk); - timer.track("docker images", "rtk docker images", &raw, &rtk); - return Ok(0); - } - let mut total_size_mb: f64 = 0.0; for line in &lines { let parts: Vec<&str> = line.split('\t').collect(); @@ -299,8 +313,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) } @@ -330,13 +345,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 +431,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 +492,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 +508,7 @@ fn kubectl_logs(args: &[String], _verbose: u8) -> Result { let label = format!("logs {}", pod); runner::run_filtered( cmd, - "kubectl", + tool, &label, |stdout| { format!( @@ -692,10 +707,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) } @@ -751,17 +767,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 +797,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 +807,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 +957,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 +970,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 +983,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 +999,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/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 f90db8b024..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 { @@ -138,7 +140,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 +153,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 +183,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,32 +212,30 @@ 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); + 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); @@ -1076,6 +1083,53 @@ 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, 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()) +} + +/// 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 +1459,130 @@ 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() { + // 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, + 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. + 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 { diff --git a/src/cmds/git/diff_cmd.rs b/src/cmds/git/diff_cmd.rs index 59c95b100b..f40c857f41 100644 --- a/src/cmds/git/diff_cmd.rs +++ b/src/cmds/git/diff_cmd.rs @@ -1,12 +1,14 @@ //! 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; 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 +19,38 @@ 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); + + let shown = never_worse(&raw, &rtk); + print!("{}", shown); + timer.track( + &format!("diff {} {}", file1.display(), file2.display()), + "rtk diff", + &raw, + shown, + ); + 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) @@ -62,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(()) } @@ -307,6 +309,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/cmds/git/git.rs b/src/cmds/git/git.rs index 29fd3b73ae..0b99f62d05 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 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 +918,7 @@ fn run_status(args: &[String], verbose: u8, global_args: &[String]) -> Result Result { + println!("{}", compact); + timer.track(&original_cmd, "rtk git commit", &raw_output, &compact); + Ok(0) } - if !stdout.trim().is_empty() { - eprint!("{}", stdout); + CommitOutcome::Failed(code) => { + 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) } - timer.track(&original_cmd, "rtk git commit", &raw_output, &raw_output); - return Ok(exit_code); } +} - Ok(0) +/// Outcome of a `git commit`: a non-success status propagates the exit code +/// rather than being reported as "ok" (#2494). +enum CommitOutcome { + Ok(String), + Failed(i32), +} + +/// Classify a `git commit` result. +fn classify_commit_outcome(success: bool, stdout: &str, exit_code: i32) -> CommitOutcome { + if success { + // Extract commit hash from output + let compact = stdout + .lines() + .next() + .map(parse_commit_output) + .unwrap_or_else(|| "ok".to_string()); + CommitOutcome::Ok(compact) + } else { + CommitOutcome::Failed(exit_code) + } } // Git push progress prefixes (stderr) — dropped from the stream. @@ -1370,6 +1389,7 @@ fn run_branch(args: &[String], verbose: u8, global_args: &[String]) -> Result Result< cmd.args(["worktree", "list"]); let result = exec_capture(&mut cmd).context("Failed to run git worktree list")?; + if !result.success() { + 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); + let filtered = never_worse(&result.stdout, &filtered).to_string(); println!("{}", filtered); timer.track( "git worktree list", @@ -1894,6 +1931,30 @@ mod tests { assert_eq!(cmd_args, vec!["status", "--porcelain", "-uno"]); } + #[test] + fn test_run_status_compact_propagates_non_repo_failure() { + // #2497: a `git status` failure other than "not a git repository" + // (here: a corrupt index) must propagate a non-zero exit, not be + // flattened into "Clean working tree" + exit 0. + let dir = tempfile::tempdir().expect("tempdir"); + let p = dir.path().to_string_lossy().into_owned(); + assert!( + Command::new("git") + .args(["-C", &p, "init", "-q"]) + .status() + .expect("git init") + .success(), + "git init should succeed" + ); + std::fs::write(dir.path().join(".git/index"), "corrupt-index").expect("corrupt index"); + let global = vec!["-C".to_string(), p]; + let code = run_status(&[], 0, &global).expect("run_status"); + assert_ne!( + code, 0, + "corrupt-index git status must not be reported as success" + ); + } + #[test] fn test_compact_diff() { let diff = r#"diff --git a/foo.rs b/foo.rs @@ -2035,6 +2096,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 = @@ -2045,6 +2122,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"; @@ -2429,6 +2516,44 @@ 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() { + 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() { + 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. 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 fc80bff696..2f6cdbb40c 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; @@ -206,16 +207,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,68 +231,199 @@ 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 = format_test_output( + framework, + &result.stdout, + &combined, + passthrough_requested, + verbose, + ); + let tee_label = format!("{}_run", framework); + + 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, + &shown, + ); + + if !result.success() { + return Ok(result.exit_code); + } + Ok(0) +} + +struct EffectiveVitestArgs { + args: Vec, + 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()]; + + 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=")) +} - let filtered = match parse_result { +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, +) -> FormattedTestOutput { + if passthrough_requested { + return format_passthrough_output(combined); + } + + let parse_result = VitestParser::parse(stdout); + let mode = FormatMode::from_verbosity(verbose); + 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 let Some(hint) = - crate::core::tee::tee_and_hint(&combined, format!("{}_run", framework).as_str(), result.exit_code) - { - println!("{}\n{}", filtered, hint); + if raw.chars().count() > max_chars { + FormattedTestOutput::truncated(text) } else { - println!("{}", filtered); + FormattedTestOutput::new(text) } +} - timer.track( - format!("{} run", framework).as_str(), - format!("rtk {} run", framework).as_str(), - &combined, - &filtered, - ); +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, + ) +} - if !result.success() { - return Ok(result.exit_code); +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) + }; + + match hint { + Some(hint) => format!("{}\n{}", filtered.text, hint), + None => filtered.text.clone(), } - Ok(0) } #[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 +528,85 @@ 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.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")); + } } 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/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/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..e2cea8a205 100644 --- a/src/cmds/system/env_cmd.rs +++ b/src/cmds/system/env_cmd.rs @@ -1,21 +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)); @@ -34,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 { @@ -64,56 +56,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,45 +116,19 @@ 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(()) } -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", @@ -216,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")); @@ -293,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/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 deleted file mode 100644 index 3f7d9a3276..0000000000 --- a/src/cmds/system/grep_cmd.rs +++ /dev/null @@ -1,510 +0,0 @@ -//! 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 anyhow::{Context, Result}; -use regex::Regex; -use std::collections::HashMap; - -#[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], - verbose: u8, -) -> Result { - let timer = tracking::TimedExecution::start(); - - if verbose > 0 { - eprintln!("grep: '{}' in {}", pattern, path); - } - - // 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 - // 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", &rg_pattern, path]); - - if let Some(ft) = file_type { - 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); - } - - let result = exec_capture(&mut rg_cmd) - .or_else(|_| { - 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); - 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, path) - } else { - format!("{} '{}' {}", extra_args.join(" "), pattern, path) - }; - - timer.track_passthrough( - &format!("grep {}", args_display), - &format!("rtk grep {} (passthrough)", args_display), - ); - return Ok(result.exit_code); - } - - let exit_code = result.exit_code; - 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()); - } - let msg = format!("0 matches for '{}'", pattern); - println!("{}", msg); - timer.track( - &format!("grep -rn '{}' {}", pattern, path), - "rtk grep", - &raw_output, - &msg, - ); - 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() - } else { - 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 { - continue; - }; - let cleaned = clean_line(content, max_line_len, context_re.as_ref(), pattern); - by_file.entry(file).or_default().push((line_num, cleaned)); - } - - let mut rtk_output = String::new(); - rtk_output.push_str(&format!( - "{} matches in {} files:\n\n", - total_matches, - by_file.len() - )); - - let mut shown = 0; - let mut files: Vec<_> = by_file.iter().collect(); - files.sort_by_key(|(f, _)| *f); - - let per_file = config::limits().grep_max_per_file; - for (file, matches) in files { - if shown >= max_results { - break; - } - - let file_display = compact_path(file); - for (line_num, content) in matches.iter().take(per_file) { - if shown >= max_results { - break; - } - 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)); - } - - print!("{}", rtk_output); - timer.track( - &format!("grep -rn '{}' {}", pattern, path), - "rtk grep", - &raw_output, - &rtk_output, - ); - - Ok(exit_code) -} - -/// 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 -/// 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)> { - lazy_static::lazy_static! { - 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)) - }) -} - -fn has_format_flag(extra_args: &[String]) -> bool { - extra_args.iter().any(|arg| { - matches!( - arg.as_str(), - "-c" | "--count" - | "-l" - | "--files-with-matches" - | "-L" - | "--files-without-match" - | "-o" - | "--only-matching" - | "-Z" - | "--null" - ) - }) -} - -fn clean_line(line: &str, max_len: usize, context_re: Option<&Regex>, pattern: &str) -> String { - let trimmed = line.trim(); - - if let Some(re) = context_re { - if let Some(m) = re.find(trimmed) { - let matched = m.as_str(); - if matched.len() <= max_len { - return matched.to_string(); - } - } - } - - if trimmed.len() <= max_len { - trimmed.to_string() - } else { - let lower = trimmed.to_lowercase(); - let pattern_lower = pattern.to_lowercase(); - - if let Some(pos) = lower.find(&pattern_lower) { - let char_pos = lower[..pos].chars().count(); - let chars: Vec = trimmed.chars().collect(); - let char_len = chars.len(); - - let start = char_pos.saturating_sub(max_len / 3); - let end = (start + max_len).min(char_len); - let start = if end == char_len { - end.saturating_sub(max_len) - } else { - start - }; - - let slice: String = chars[start..end].iter().collect(); - if start > 0 && end < char_len { - format!("...{}...", slice) - } else if start > 0 { - format!("...{}", slice) - } else { - format!("{}...", slice) - } - } else { - let t: String = trimmed.chars().take(max_len - 3).collect(); - format!("{}...", t) - } - } -} - -fn compact_path(path: &str) -> String { - if path.len() <= 50 { - return path.to_string(); - } - - let parts: Vec<&str> = path.split('/').collect(); - if parts.len() <= 3 { - return path.to_string(); - } - - format!( - "{}/.../{}/{}", - parts[0], - parts[parts.len() - 2], - parts[parts.len() - 1] - ) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_clean_line() { - let line = " const result = someFunction();"; - let cleaned = clean_line(line, 50, None, "result"); - assert!(!cleaned.starts_with(' ')); - assert!(cleaned.len() <= 50); - } - - #[test] - fn test_compact_path() { - let path = "/Users/patrick/dev/project/src/components/Button.tsx"; - let compact = compact_path(path); - 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 - let line = " สวัสดีครับ นี่คือข้อความที่ยาวมากสำหรับทดสอบ "; - let cleaned = clean_line(line, 20, None, "ครับ"); - // Should not panic - assert!(!cleaned.is_empty()); - } - - #[test] - fn test_clean_line_emoji() { - let line = "🎉🎊🎈🎁🎂🎄 some text 🎃🎆🎇✨"; - let cleaned = clean_line(line, 15, None, "text"); - 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"); - } - - // Fix: -r flag (grep recursive) is stripped from extra_args (rg is recursive by default) - #[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"); - } - - // --- truncation accuracy --- - - #[test] - 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). - let per_file = config::limits().grep_max_per_file; - let total_matches = per_file + 42; - let overflow = total_matches - per_file; - assert_eq!(overflow, 42, "overflow must equal true suppressed count"); - // Demonstrate why capping before subtraction is wrong: - let hypothetical_cap = per_file + 5; - let capped = total_matches.min(hypothetical_cap); - let wrong_overflow = capped - per_file; - assert_ne!( - wrong_overflow, overflow, - "capping before subtraction gives wrong overflow" - ); - } - - // --- format flag detection --- - - #[test] - fn test_format_flag_detects_count() { - assert!(has_format_flag(&["-c".to_string()])); - assert!(has_format_flag(&["--count".to_string()])); - } - - #[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()])); - } - - #[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()])); - } - - #[test] - fn test_format_flag_detects_only_matching() { - assert!(has_format_flag(&["-o".to_string()])); - assert!(has_format_flag(&["--only-matching".to_string()])); - } - - #[test] - fn test_format_flag_detects_null() { - assert!(has_format_flag(&["-Z".to_string()])); - assert!(has_format_flag(&["--null".to_string()])); - } - - #[test] - fn test_format_flag_ignores_normal_flags() { - assert!(!has_format_flag(&[ - "-i".to_string(), - "-w".to_string(), - "-A".to_string(), - "3".to_string(), - ])); - } - - // 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] - fn test_rg_always_has_line_numbers() { - // grep_cmd::run() always passes "-n" to rg (line 24). - // 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", "."]); - // If rg is available, it should accept -n without error (exit 1 = no match, not error) - if let Ok(output) = cmd.output() { - assert!( - output.status.code() == Some(1) || output.status.success(), - "rg -n should be accepted" - ); - } - // If rg is not installed, skip gracefully (test still passes) - } - - // --- issue #1436: parse_match_line robustness --- - // 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(); - assert_eq!(file, "file.php"); - assert_eq!(line_num, 10); - assert_eq!(content, "use Foo\\Bar;"); - } - - // Issue #1436 reproducer: content with `::` must not split into a phantom - // file bucket. With NUL separation between file and line:content, content - // colons are irrelevant to the parser. - #[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(); - assert_eq!(file, "externalImportShell.class.php"); - assert_eq!(line_num, 81); - assert_eq!( - content, - " $this->queueProcessModel = ClassRegistry::init('Collections.QueueProcess');" - ); - } - - // Windows abs-path safety: drive letter + backslashes must not break the - // parser. The NUL separator makes the file portion unambiguous. - #[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(); - assert_eq!(file, r"C:\src\file.rs"); - assert_eq!(line_num, 42); - assert_eq!(content, "fn main() {}"); - } - - // Filenames containing `:digits:` (which would fool a greedy `:` parser) - // must still parse correctly under NUL separation. - #[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(); - assert_eq!(file, "badly_named:52:file.txt"); - assert_eq!(line_num, 1); - assert_eq!(content, "xxx"); - } - - // Content that itself contains `:digits:` (e.g. log lines, port numbers, - // line-number-like substrings) must not confuse the parser. - #[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(); - assert_eq!(file, "log.txt"); - assert_eq!(line_num, 7); - assert_eq!(content, "debug: counter is :42: now"); - } - - #[test] - fn test_parse_match_line_malformed_returns_none() { - // No NUL separator (e.g. rg/grep invoked without --null/-Z, or a - // context line written with `-`). - assert!(parse_match_line("file.rs:1:content").is_none()); - assert!(parse_match_line("not a match line").is_none()); - // Missing line number after NUL - assert!(parse_match_line("file.rs\x00fn foo()").is_none()); - // Empty - assert!(parse_match_line("").is_none()); - } - - #[test] - fn test_parse_match_line_empty_content() { - let line = "file.rs\x007:"; - let (file, line_num, content) = parse_match_line(line).unwrap(); - assert_eq!(file, "file.rs"); - assert_eq!(line_num, 7); - assert_eq!(content, ""); - } - - #[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) - } -} 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/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(()) } diff --git a/src/cmds/system/read.rs b/src/cmds/system/read.rs index 2f56687ee3..141d55acf2 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; @@ -65,17 +66,21 @@ 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()) }; - print!("{}", rtk_output); + let shown = never_worse(&raw, &rtk_output); + print!("{}", shown); timer.track( &format!("cat {}", file.display()), "rtk read", - &content, - &rtk_output, + &raw, + shown, ); Ok(()) } @@ -129,14 +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()) }; - print!("{}", rtk_output); + let shown = never_worse(&raw, &rtk_output); + print!("{}", shown); - timer.track("cat - (stdin)", "rtk read -", &content, &rtk_output); + timer.track("cat - (stdin)", "rtk read -", &raw, shown); Ok(()) } diff --git a/src/cmds/system/search.rs b/src/cmds/system/search.rs new file mode 100644 index 0000000000..ffdd93c166 --- /dev/null +++ b/src/cmds/system/search.rs @@ -0,0 +1,1379 @@ +//! 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, exec_capture_stdin, CaptureResult}; +use crate::core::tracking; +use crate::core::utils::{resolved_command, strip_ansi}; +use crate::core::{args_utils, config}; +use anyhow::{Context, Result}; +use regex::Regex; +use std::collections::HashMap; + +/// 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"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", + "--ignore-file", + "--max-columns", + "--max-count", + "--max-depth", + "--max-filesize", + "--path-separator", + "--pre", + "--pre-glob", + "--replace", + "--sort", + "--sortr", + "--threads", + "--type", + "--type-add", + "--type-clear", + "--type-not", +]; + +/// 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 `-`). +/// +/// 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 is its inline value, returned verbatim. +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 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, + inline, + }; + } + raw_prefix.push(ch as char); + j += 1; + } + ClusterResult::Boolean((!raw_prefix.is_empty()).then_some(raw_prefix)) +} + +/// 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() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) + .collect(); + let tail = &cleaned[cleaned.len().saturating_sub(32)..]; + format!("grep_{}_{}", idx, tail) +} + +/// 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)); + } + s +} + +/// Extracts `(patterns, paths, flags)` from the raw trailing args. +/// +/// - `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). +/// +/// 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(); + let mut flags: Vec = Vec::new(); + let mut past_dashdash = false; + let mut i = 0; + + while i < args.len() { + let arg = args[i].as_ref(); + + if past_dashdash { + positionals.push(arg.to_string()); + i += 1; + continue; + } + + if arg == "--" { + past_dashdash = true; + i += 1; + continue; + } + + 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; + } + flags.push(arg.to_string()); + i += 1; + continue; + } + + match arg.strip_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; + } + } 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 { + i += 1; + } + } + } + }, + _ => { + positionals.push(arg.to_string()); + i += 1; + } + } + } + + // 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 { + let paths = positionals.iter().skip(1).cloned().collect(); + let patterns = positionals.into_iter().take(1).collect(); + (patterns, paths) + }; + + (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 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() + } + + /// `-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"], + 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 cmd = resolved_command(engine.bin()); + cmd.args(engine.parse_flags()); + 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_stdin(&mut cmd).context("search failed") +} + +/// 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, + args: &[T], + real_cmd: &str, +) -> Result { + 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() { + eprint!("{}", result.stderr); + } + + timer.track_passthrough(real_cmd, &format!("rtk {} (passthrough)", real_cmd)); + 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, + max_results: usize, + context_only: bool, + args: &[String], + verbose: u8, +) -> Result { + let timer = tracking::TimedExecution::start(); + + // --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 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); + } + return Ok(result.exit_code); + } + + // 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); + + if patterns.is_empty() { + return passthrough(&timer, engine, &args, &real_cmd); + } + + let pattern_display = if patterns.len() == 1 { + patterns[0].clone() + } else { + patterns.join("|") + }; + + let path_display = paths.join(" "); + + if verbose > 0 { + eprintln!("grep: '{}' in {}", 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, &args, &real_cmd); + } + + let result = engine_capture(engine, &extra_args, &patterns, &paths)?; + + 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() { + timer.track(&real_cmd, &rtk_label, &raw_output, ""); + return Ok(exit_code); + } + + let context_re = if context_only { + Regex::new(&format!( + "(?i).{{0,20}}{}.*", + regex::escape(&pattern_display) + )) + .ok() + } else { + None + }; + + 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, is_match, cleaned)); + } + + let total_matches: usize = by_file + .values() + .flat_map(|v| v.iter()) + .filter(|(_, is_match, _)| *is_match) + .count(); + + // 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 per_file = config::limits().grep_max_per_file; + let mut files: Vec<_> = by_file.iter().collect(); + files.sort_by_key(|(f, _)| *f); + + 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() { + if shown >= max_results { + 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; + } + let sep = if *is_match { ':' } else { '-' }; + 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; + } + + 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) => { + body.push_str(&format!(" +{} more in {} {}\n", remaining, file_display, hint)) + } + 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)) + .unwrap_or_default(); + 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 + }; + + let output = if capped && rtk_output.len() < plain.len() { + rtk_output + } else { + plain + }; + + print!("{}", output); + timer.track(&real_cmd, &rtk_label, &raw_output, &output); + + Ok(exit_code) +} + +/// 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: +/// - 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. +/// 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(); + } + MATCH_LINE_RE.captures(line).and_then(|caps| { + 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)) + }) +} + +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| { + 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 + } + }) +} + +fn clean_line(line: &str, max_len: usize, context_re: Option<&Regex>, pattern: &str) -> String { + let trimmed = line.trim(); + + if let Some(re) = context_re { + if let Some(m) = re.find(trimmed) { + let matched = m.as_str(); + if matched.len() <= max_len { + return matched.to_string(); + } + } + } + + if trimmed.len() <= max_len { + trimmed.to_string() + } else { + let lower = trimmed.to_lowercase(); + let pattern_lower = pattern.to_lowercase(); + + if let Some(pos) = lower.find(&pattern_lower) { + let char_pos = lower[..pos].chars().count(); + let chars: Vec = trimmed.chars().collect(); + let char_len = chars.len(); + + let start = char_pos.saturating_sub(max_len / 3); + let end = (start + max_len).min(char_len); + let start = if end == char_len { + end.saturating_sub(max_len) + } else { + start + }; + + let slice: String = chars[start..end].iter().collect(); + if start > 0 && end < char_len { + format!("...{}...", slice) + } else if start > 0 { + format!("...{}", slice) + } else { + format!("{}...", slice) + } + } else { + let t: String = trimmed.chars().take(max_len - 3).collect(); + format!("{}...", t) + } + } +} + +fn compact_path(path: &str) -> String { + if path.len() <= 50 { + return path.to_string(); + } + + let parts: Vec<&str> = path.split('/').collect(); + if parts.len() <= 3 { + return path.to_string(); + } + + format!( + "{}/.../{}/{}", + parts[0], + parts[parts.len() - 2], + parts[parts.len() - 1] + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_clean_line() { + let line = " const result = someFunction();"; + let cleaned = clean_line(line, 50, None, "result"); + assert!(!cleaned.starts_with(' ')); + assert!(cleaned.len() <= 50); + } + + #[test] + fn test_compact_path() { + let path = "/Users/patrick/dev/project/src/components/Button.tsx"; + let compact = compact_path(path); + assert!(compact.len() <= 60); + } + + #[test] + fn test_clean_line_multibyte() { + // Thai text that exceeds max_len in bytes + let line = " สวัสดีครับ นี่คือข้อความที่ยาวมากสำหรับทดสอบ "; + let cleaned = clean_line(line, 20, None, "ครับ"); + // Should not panic + assert!(!cleaned.is_empty()); + } + + #[test] + fn test_clean_line_emoji() { + let line = "🎉🎊🎈🎁🎂🎄 some text 🎃🎆🎇✨"; + let cleaned = clean_line(line, 15, None, "text"); + assert!(!cleaned.is_empty()); + } + + // --- 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 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("rn".to_string())) + ); + assert_eq!( + parse_cluster("Rni"), + ClusterResult::Boolean(Some("Rni".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() { + // 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() { + // r passes through in the prefix; A is value-taking + assert_eq!(parse_cluster("rA"), vt(Some("r"), '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")); + } + + // --- extract_pattern_path --- + + #[test] + fn test_extract_simple() { + 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(&["-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(&["-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_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!["-rn"]); + } + + #[test] + fn test_extract_cluster_ending_in_e() { + // -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!["-rn"]); + } + + #[test] + fn test_extract_cluster_ending_in_value_flag() { + // -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!["-r", "-A", "2"]); + } + + #[test] + fn test_extract_multi_path() { + 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(&["-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(&["-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(&["-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(&["--", "--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::<&str>(&[]); + 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(&["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"]); + } + + // --- 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] + 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 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; + assert_eq!(overflow, 42, "overflow must equal true suppressed count"); + // Demonstrate why capping before subtraction is wrong: + let hypothetical_cap = per_file + 5; + let capped = total_matches.min(hypothetical_cap); + let wrong_overflow = capped - per_file; + assert_ne!( + wrong_overflow, overflow, + "capping before subtraction gives wrong overflow" + ); + } + + // --- format flag detection --- + + #[test] + fn test_format_flag_detects_count() { + assert!(has_format_flag(&["-c"])); + assert!(has_format_flag(&["--count"])); + } + + #[test] + fn test_format_flag_detects_files_with_matches() { + 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"])); + assert!(has_format_flag(&["--files-without-match"])); + } + + #[test] + fn test_format_flag_detects_only_matching() { + assert!(has_format_flag(&["-o"])); + assert!(has_format_flag(&["--only-matching"])); + } + + #[test] + fn test_format_flag_detects_null() { + assert!(has_format_flag(&["-Z"])); + assert!(has_format_flag(&["--null"])); + } + + #[test] + fn test_format_flag_ignores_normal_flags() { + assert!(!has_format_flag(&["-i", "-w", "-A", "3"])); + } + + #[test] + 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_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_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 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() { + // 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", "."]); + // If rg is available, it should accept -n without error (exit 1 = no match, not error) + if let Ok(output) = cmd.output() { + assert!( + output.status.code() == Some(1) || output.status.success(), + "rg -n should be accepted" + ); + } + // If rg is not installed, skip gracefully (test still passes) + } + + // --- issues #1436 / #1613: parse_match_line robustness (single-file colon misparse) --- + // 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, 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;"); + } + + // Issue #1436 reproducer: content with `::` must not split into a phantom + // file bucket. With NUL separation between file and line:content, content + // colons are irrelevant to the parser. + #[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, 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');" + ); + } + + // Windows abs-path safety: drive letter + backslashes must not break the + // parser. The NUL separator makes the file portion unambiguous. + #[test] + fn test_parse_match_line_windows_path() { + let line = "C:\\src\\file.rs\x0042:fn main() {}"; + 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() {}"); + } + + // Filenames containing `:digits:` (which would fool a greedy `:` parser) + // must still parse correctly under NUL separation. + #[test] + fn test_parse_match_line_filename_with_colons() { + let line = "badly_named:52:file.txt\x001:xxx"; + 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"); + } + + // Content that itself contains `:digits:` (e.g. log lines, port numbers, + // line-number-like substrings) must not confuse the parser. + #[test] + fn test_parse_match_line_content_with_digit_colons() { + let line = "log.txt\x007:debug: counter is :42: now"; + 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"); + } + + #[test] + fn test_parse_match_line_malformed_returns_none() { + // No NUL separator (e.g. rg/grep invoked without --null/-Z, or a + // context line written with `-`). + assert!(parse_match_line("file.rs:1:content").is_none()); + assert!(parse_match_line("not a match line").is_none()); + // Missing line number after NUL + assert!(parse_match_line("file.rs\x00fn foo()").is_none()); + // Empty + assert!(parse_match_line("").is_none()); + } + + #[test] + fn test_parse_match_line_empty_content() { + let line = "file.rs\x007:"; + 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"); + } + + // --- 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/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/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/src/core/toml_filter.rs b/src/core/toml_filter.rs index f752a21690..164b3e69bb 100644 --- a/src/core/toml_filter.rs +++ b/src/core/toml_filter.rs @@ -1589,6 +1589,11 @@ match_command = "^make\\b" "poetry-install", "pre-commit", "ps", + "pulumi-destroy", + "pulumi-preview", + "pulumi-refresh", + "pulumi-stack", + "pulumi-up", "quarto-render", "rsync", "shellcheck", @@ -1622,8 +1627,8 @@ match_command = "^make\\b" let filters = make_filters(BUILTIN_TOML); assert_eq!( filters.len(), - 58, - "Expected exactly 58 built-in filters, got {}. \ + 63, + "Expected exactly 63 built-in filters, got {}. \ Update this count when adding/removing filters in src/filters/.", filters.len() ); @@ -1680,11 +1685,11 @@ expected = "output line 1\noutput line 2" let combined = format!("{}\n\n{}", BUILTIN_TOML, new_filter); let filters = make_filters(&combined); - // All 58 existing filters still present + 1 new = 59 + // All 63 existing filters still present + 1 new = 64 assert_eq!( filters.len(), - 59, - "Expected 59 filters after concat (58 built-in + 1 new)" + 64, + "Expected 64 filters after concat (63 built-in + 1 new)" ); // New filter is discoverable diff --git a/src/discover/registry.rs b/src/discover/registry.rs index 59651a497c..b459cb02b0 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; @@ -1417,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()) ); } @@ -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!( diff --git a/src/discover/rules.rs b/src/discover/rules.rs index 6bb5741c8e..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: &[], @@ -389,6 +398,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", @@ -743,6 +761,21 @@ 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: 45.0, + subcmd_savings: &[ + ("up", 66.0), + ("destroy", 72.0), + ("refresh", 35.0), + ("preview", 25.0), + ("stack", 29.0), + ], + 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..1052c0ff66 --- /dev/null +++ b/src/filters/pulumi-destroy.toml @@ -0,0 +1,60 @@ +[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*, - /// Show all (include sensitive) - #[arg(long)] - show_all: bool, }, /// Find files with compact tree output (accepts native find flags like -name, -type) @@ -293,6 +290,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 @@ -302,11 +305,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 +317,14 @@ 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, + }, + + /// 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, }, @@ -990,6 +992,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) @@ -1235,16 +1272,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); @@ -1327,6 +1362,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 @@ -1700,8 +1755,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 } @@ -1712,11 +1767,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 } => { @@ -1769,60 +1824,59 @@ 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)? } Commands::Grep { - pattern, - path, max_len, max, context_only, - file_type, - line_numbers: _, // no-op: line numbers always enabled in grep_cmd::run + file_type: _, extra_args, - } => grep_cmd::run( - &pattern, - &path, + } => 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, @@ -2522,8 +2576,10 @@ fn is_operational_command(cmd: &Commands) -> bool { | Commands::Dotnet { .. } | Commands::Docker { .. } | Commands::Kubectl { .. } + | Commands::Oc { .. } | Commands::Summary { .. } | Commands::Grep { .. } + | Commands::Rg { .. } | Commands::Wget { .. } | Commands::Vitest { .. } | Commands::Prisma { .. } @@ -2710,6 +2766,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(); @@ -2852,6 +2932,7 @@ mod tests { "ls", "tree", "read", + "rg", "git", "gh", "glab", @@ -2867,6 +2948,7 @@ mod tests { "dotnet", "docker", "kubectl", + "oc", "summary", "grep", "wget", 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 + } + ] + } + } + ] +} 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:?}" + ); +} diff --git a/tests/grep_faithful_format_test.rs b/tests/grep_faithful_format_test.rs new file mode 100644 index 0000000000..5ce9e3a7cd --- /dev/null +++ b/tests/grep_faithful_format_test.rs @@ -0,0 +1,164 @@ +#![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 +} + +// #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(); + 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/guard_integration_test.rs b/tests/guard_integration_test.rs new file mode 100644 index 0000000000..b776918003 --- /dev/null +++ b/tests/guard_integration_test.rs @@ -0,0 +1,153 @@ +//! 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"); + // 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:?}" + ); + 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" + ); +} diff --git a/tests/search_compress_test.rs b/tests/search_compress_test.rs new file mode 100644 index 0000000000..1a36c08399 --- /dev/null +++ b/tests/search_compress_test.rs @@ -0,0 +1,378 @@ +#![cfg(unix)] +//! 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; + +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 single_file_context_shown_without_header() { + 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("matches in"), + "single-file search must not add a grouped header:\n{stdout}" + ); + assert!( + stdout.contains("after1") && stdout.contains("after2"), + "after-context lines must be shown:\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", "-nA1", "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 capped_single_file_shows_header() { + if !rg_available() { + return; + } + 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()]) + .output() + .expect("rtk grep"); + let stdout = String::from_utf8_lossy(&out.stdout); + + assert!( + stdout.contains("matches in"), + "header must show once capping 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(["rg", "-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(["rg", "--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(["rg", "-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(["rg", "--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() +} + +// Covers #545: grep savings are measured against the real grep output. +#[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..60 { + 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.trim() == "1:foo", + "single-file grep must equal `grep -n` (position, no filename):\n{stdout}" + ); + assert!( + !stdout.contains("matches in"), + "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}" + ); +} + +// 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" + ); +} 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" + ); +} diff --git a/tests/search_faithful_test.rs b/tests/search_faithful_test.rs new file mode 100644 index 0000000000..e821d39eba --- /dev/null +++ b/tests/search_faithful_test.rs @@ -0,0 +1,348 @@ +#![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::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") + .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..60 { + 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}" + ); +} + +// --- 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}" + ); +} + +// #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" + ); +}