use super::*;
async fn run_grep(args: serde_json::Value, ws: &std::path::Path) -> String {
run_tool("grep", args, ws, &caveats_rw(ws), None).await
}
#[tokio::test]
async fn grep_finds_regex_in_files_without_shell() {
let ws = tempfile::TempDir::new().unwrap();
touch(ws.path(), "a.rs");
std::fs::write(ws.path().join("a.rs"), b"one\ntwo\nthree\n").unwrap();
touch(ws.path(), "b.rs");
std::fs::write(ws.path().join("b.rs"), b"FOO\nbar\n").unwrap();
let out = run_grep(serde_json::json!({ "pattern": "bar" }), ws.path()).await;
assert_eq!(out, "b.rs:2:bar", "got: {out}");
}
#[tokio::test]
async fn grep_runs_when_shell_is_unavailable() {
let ws = tempfile::TempDir::new().unwrap();
touch(ws.path(), "a.rs");
std::fs::write(ws.path().join("a.rs"), b"panic: boom\nok\n").unwrap();
let out = run_grep(serde_json::json!({ "pattern": "panic" }), ws.path()).await;
assert_eq!(out, "a.rs:1:panic: boom", "got: {out}");
}
#[tokio::test]
async fn grep_case_insensitive_match() {
let ws = tempfile::TempDir::new().unwrap();
touch(ws.path(), "a.rs");
std::fs::write(ws.path().join("a.rs"), b"HELLO\nworld\nHello there\n").unwrap();
let out = run_grep(
serde_json::json!({ "pattern": "hello", "ignore_case": true }),
ws.path(),
)
.await;
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines.len(), 2, "got: {out}");
assert!(
out.contains("a.rs:1:HELLO") && out.contains("a.rs:3:Hello there"),
"got: {out}"
);
}
#[tokio::test]
async fn grep_emits_context_lines_with_dash_slot() {
let ws = tempfile::TempDir::new().unwrap();
touch(ws.path(), "a.rs");
std::fs::write(ws.path().join("a.rs"), b"line1\nline2\nHIT\nline4\nline5\n").unwrap();
let out = run_grep(
serde_json::json!({ "pattern": "HIT", "context": 1 }),
ws.path(),
)
.await;
assert_eq!(out, "a.rs-2-line2\na.rs:3:HIT\na.rs-4-line4", "got: {out}");
}
#[tokio::test]
async fn grep_skips_binary_files() {
let ws = tempfile::TempDir::new().unwrap();
touch(ws.path(), "a.rs");
std::fs::write(ws.path().join("a.rs"), b"\x00\x00\x00\x00\x00").unwrap();
let out = run_grep(serde_json::json!({ "pattern": "." }), ws.path()).await;
assert!(out.contains("no matches"), "got: {out}");
}
#[tokio::test]
async fn grep_glob_restricts_search() {
let ws = tempfile::TempDir::new().unwrap();
touch(ws.path(), "src/a.rs");
std::fs::write(ws.path().join("src/a.rs"), b"needle\n").unwrap();
touch(ws.path(), "docs/a.rs");
std::fs::write(ws.path().join("docs/a.rs"), b"needle\n").unwrap();
let out = run_grep(
serde_json::json!({ "pattern": "needle", "glob": "src/**" }),
ws.path(),
)
.await;
assert_eq!(out, "src/a.rs:1:needle", "got: {out}");
}
#[tokio::test]
async fn grep_rejects_junk_context() {
let ws = tempfile::TempDir::new().unwrap();
touch(ws.path(), "a.rs");
std::fs::write(ws.path().join("a.rs"), b"x\n").unwrap();
let out = run_grep(
serde_json::json!({ "pattern": "x", "context": "lots" }),
ws.path(),
)
.await;
assert!(out.starts_with("error"), "got: {out}");
}
#[tokio::test]
async fn grep_requires_pattern() {
let ws = tempfile::TempDir::new().unwrap();
touch(ws.path(), "a.rs");
let out = run_grep(serde_json::json!({}), ws.path()).await;
assert!(
out.starts_with("error: grep: `pattern` is required"),
"got: {out}"
);
}
#[tokio::test]
async fn grep_max_results_caps_and_notes_truncation() {
let ws = tempfile::TempDir::new().unwrap();
touch(ws.path(), "a.rs");
let body: String = (0..50).map(|i| format!("row{i}\n")).collect();
std::fs::write(ws.path().join("a.rs"), body.as_bytes()).unwrap();
let out = run_grep(
serde_json::json!({ "pattern": "row", "max_results": 3 }),
ws.path(),
)
.await;
let hits = out.lines().filter(|l| l.contains(":row")).count();
assert_eq!(hits, 3, "got: {out}");
assert!(out.contains("[stopped at 3 results"), "got: {out}");
}
#[tokio::test]
async fn grep_refuses_root_outside_workspace() {
let parent = tempfile::TempDir::new().unwrap();
std::fs::write(parent.path().join("outside.txt"), b"secret\n").unwrap();
let ws = parent.path().join("ws");
std::fs::create_dir_all(&ws).unwrap();
for path in [
"..".to_string(),
parent.path().to_string_lossy().into_owned(),
] {
for fs_read in [Scope::All, Scope::none()] {
let caveats = Caveats {
fs_read,
..caveats_rw(&ws)
};
let mut gate = MockGate::new(true, &caveats);
let out = run_tool_gated(
"grep",
serde_json::json!({ "pattern": ".", "path": path }),
&ws,
&caveats,
&mut gate,
)
.await;
assert!(out.starts_with("capability denied"), "got: {out}");
assert!(out.contains("workspace-only"), "got: {out}");
assert!(!out.contains("request_permissions"), "got: {out}");
assert!(gate.asks.is_empty(), "an unsupported root must not prompt");
}
}
}
#[tokio::test]
async fn grep_denied_without_fs_read() {
let ws = tempfile::TempDir::new().unwrap();
touch(ws.path(), "secret.txt");
std::fs::write(ws.path().join("secret.txt"), b"top secret\n").unwrap();
let denied = Caveats {
fs_read: Scope::none(),
..caveats_rw(ws.path())
};
let out = run_tool(
"grep",
serde_json::json!({ "pattern": "secret" }),
ws.path(),
&denied,
None,
)
.await;
assert!(out.starts_with("capability denied"), "got: {out}");
let mut gate = MockGate::new(true, &denied);
let out = run_tool_gated(
"grep",
serde_json::json!({ "pattern": "secret" }),
ws.path(),
&denied,
&mut gate,
)
.await;
assert!(out.contains("secret.txt:1:top secret"), "got: {out}");
assert_eq!(
gate.asks.len(),
1,
"in-workspace authority remains grantable"
);
}
#[tokio::test]
async fn grep_searches_a_file_with_invalid_utf8() {
let ws = tempfile::TempDir::new().unwrap();
std::fs::write(ws.path().join("a.txt"), b"caf\xe9\nNEEDLE here\n").unwrap();
let out = run_grep(serde_json::json!({ "pattern": "NEEDLE" }), ws.path()).await;
assert_eq!(out, "a.txt:2:NEEDLE here", "got: {out}");
}
#[tokio::test]
async fn grep_reports_the_right_line_in_a_large_file() {
let ws = tempfile::TempDir::new().unwrap();
let body = (1..=13_000)
.map(|n| {
if n == 9_000 {
"NEEDLE".to_string()
} else {
format!("line {n}")
}
})
.collect::<Vec<_>>()
.join("\n");
std::fs::write(ws.path().join("big.rs"), body).unwrap();
let out = run_grep(serde_json::json!({ "pattern": "^NEEDLE$" }), ws.path()).await;
assert_eq!(out, "big.rs:9000:NEEDLE", "got: {out}");
}
#[tokio::test]
async fn grep_rejects_junk_max_results() {
let ws = tempfile::TempDir::new().unwrap();
touch(ws.path(), "a.rs");
let out = run_grep(
serde_json::json!({ "pattern": "x", "max_results": "lots" }),
ws.path(),
)
.await;
assert!(out.starts_with("error: grep: `max_results`"), "got: {out}");
}
#[tokio::test]
async fn a_late_binary_file_is_rolled_back_and_reported() {
let ws = tempfile::TempDir::new().unwrap();
let mut late = b"NEEDLE first\n".to_vec();
late.extend(std::iter::repeat_n(b"filler line\n".as_slice(), 40_000).flatten());
late.extend(b"\0tail\n");
std::fs::write(ws.path().join("a.bin"), late).unwrap();
std::fs::write(ws.path().join("b.txt"), b"NEEDLE second\n").unwrap();
let out = run_grep(
serde_json::json!({ "pattern": "NEEDLE", "max_results": 1 }),
ws.path(),
)
.await;
assert!(out.starts_with("b.txt:1:NEEDLE second"), "got: {out}");
assert!(!out.contains("a.bin"), "got: {out}");
assert!(
!out.contains("[stopped at"),
"a rolled-back hit must not consume the cap: {out}"
);
assert!(out.contains("[1 file(s) not searched"), "got: {out}");
}
#[cfg(unix)]
#[tokio::test]
async fn an_unreadable_file_is_reported_not_hidden() {
use std::os::unix::fs::PermissionsExt;
let ws = tempfile::TempDir::new().unwrap();
let f = ws.path().join("locked.txt");
std::fs::write(&f, b"NEEDLE\n").unwrap();
std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o000)).unwrap();
if std::fs::read(&f).is_ok() {
return; }
let out = run_grep(serde_json::json!({ "pattern": "NEEDLE" }), ws.path()).await;
std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o644)).unwrap();
assert!(out.starts_with("no matches"), "got: {out}");
assert!(out.contains("[1 file(s) not searched"), "got: {out}");
}
#[tokio::test]
async fn a_zero_cap_is_rejected() {
let ws = tempfile::TempDir::new().unwrap();
std::fs::write(ws.path().join("a.rs"), b"x\n").unwrap();
let out = run_grep(
serde_json::json!({ "pattern": "x", "max_results": 0 }),
ws.path(),
)
.await;
assert!(
out.starts_with("error: grep: `max_results` must be at least 1"),
"got: {out}"
);
}