379 lines
11 KiB
Rust
379 lines
11 KiB
Rust
#![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"
|
|
);
|
|
}
|