use async_trait::async_trait;
use bamboo_agent_core::{Tool, ToolClass, ToolCtx, ToolError, ToolOutcome, ToolResult};
use globset::{GlobBuilder, GlobSet};
use regex::{Regex, RegexBuilder};
use serde::Deserialize;
use std::collections::{BTreeSet, HashMap};
use std::path::{Path, PathBuf};
use super::{parameter_schema, search_traversal, workspace_state};
const DEFAULT_HEAD_LIMIT: usize = 200;
const MAX_RESULT_BYTES: usize = 256 * 1024;
const MAX_MATCHES: usize = 2_000;
const MAX_SCANNED_FILES: usize = 50_000;
const MAX_FILE_BYTES: u64 = 2 * 1024 * 1024;
const SEARCH_SCOPE_TOO_BROAD_ERROR: &str =
"Search scope too broad. Add path/glob/type or reduce pattern.";
const MULTILINE_REQUIRES_NARROWED_PATH_ERROR: &str = "Multiline grep requires narrowed path.";
const RESULT_TOO_LARGE_ERROR: &str = "Result too large; refine query and retry.";
#[derive(Debug, Deserialize, Clone, Copy, Default, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
#[schemars(inline)]
enum OutputMode {
Content,
#[default]
FilesWithMatches,
Count,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[schemars(deny_unknown_fields)]
struct GrepArgs {
pattern: String,
#[serde(default)]
#[schemars(with = "String", skip_serializing_if = "Option::is_none")]
path: Option<String>,
#[serde(default)]
#[schemars(with = "String", skip_serializing_if = "Option::is_none")]
glob: Option<String>,
#[serde(default)]
#[schemars(with = "OutputMode", skip_serializing_if = "Option::is_none")]
output_mode: Option<OutputMode>,
#[serde(rename = "-B", default)]
#[schemars(
schema_with = "parameter_schema::number",
skip_serializing_if = "Option::is_none"
)]
before: Option<usize>,
#[serde(rename = "-A", default)]
#[schemars(
schema_with = "parameter_schema::number",
skip_serializing_if = "Option::is_none"
)]
after: Option<usize>,
#[serde(rename = "-C", default)]
#[schemars(
schema_with = "parameter_schema::number",
skip_serializing_if = "Option::is_none"
)]
context: Option<usize>,
#[serde(rename = "-n", default)]
#[schemars(with = "bool", skip_serializing_if = "Option::is_none")]
line_numbers: Option<bool>,
#[serde(rename = "-i", default)]
#[schemars(with = "bool", skip_serializing_if = "Option::is_none")]
case_insensitive: Option<bool>,
#[serde(default)]
#[schemars(with = "String", skip_serializing_if = "Option::is_none")]
r#type: Option<String>,
#[serde(default)]
#[schemars(
schema_with = "parameter_schema::number",
skip_serializing_if = "Option::is_none"
)]
head_limit: Option<usize>,
#[serde(default)]
#[schemars(with = "bool", skip_serializing_if = "Option::is_none")]
multiline: Option<bool>,
#[serde(default)]
include_ignored: bool,
}
pub struct GrepTool;
impl GrepTool {
pub fn new() -> Self {
Self
}
fn extension_map() -> HashMap<&'static str, &'static [&'static str]> {
HashMap::from([
("js", &["js", "mjs", "cjs"] as &[_]),
("ts", &["ts", "tsx"]),
("py", &["py"]),
("rust", &["rs"]),
("go", &["go"]),
("java", &["java"]),
("cpp", &["cc", "cpp", "cxx", "hpp", "h"]),
("c", &["c", "h"]),
("json", &["json"]),
("yaml", &["yaml", "yml"]),
("toml", &["toml"]),
("md", &["md", "markdown"]),
])
}
fn collect_files(
base: &Path,
type_filter: Option<&str>,
include_ignored: bool,
) -> Vec<PathBuf> {
#[cfg(test)]
tests::pause_directory_discovery(base);
let ext_map = Self::extension_map();
let allowed_ext = type_filter.and_then(|name| ext_map.get(name).copied());
let mut files = Vec::new();
for entry in search_traversal::walk(base, include_ignored).filter_map(|entry| entry.ok()) {
if !entry.file_type().is_some_and(|kind| kind.is_file()) {
continue;
}
if files.len() >= MAX_SCANNED_FILES {
break;
}
let path = entry.path();
if let Some(extensions) = allowed_ext {
let ext = path
.extension()
.and_then(|v| v.to_str())
.unwrap_or_default();
if !extensions.iter().any(|candidate| candidate == &ext) {
continue;
}
}
files.push(path.to_path_buf());
}
files
}
fn compile_glob(glob: Option<&str>) -> Result<Option<GlobSet>, ToolError> {
let Some(pattern) = glob else {
return Ok(None);
};
let mut builder = globset::GlobSetBuilder::new();
let glob = GlobBuilder::new(pattern)
.literal_separator(false)
.build()
.map_err(|e| ToolError::InvalidArguments(format!("Invalid glob pattern: {}", e)))?;
builder.add(glob);
builder
.build()
.map(Some)
.map_err(|e| ToolError::Execution(format!("Failed to compile glob: {}", e)))
}
fn compile_regex(
pattern: &str,
case_insensitive: bool,
multiline: bool,
) -> Result<Regex, ToolError> {
let mut builder = RegexBuilder::new(pattern);
builder.case_insensitive(case_insensitive);
builder.dot_matches_new_line(multiline);
builder.multi_line(multiline);
builder
.build()
.map_err(|e| ToolError::InvalidArguments(format!("Invalid regex pattern: {}", e)))
}
fn byte_to_line(line_starts: &[usize], byte: usize) -> usize {
match line_starts.binary_search(&byte) {
Ok(idx) => idx,
Err(idx) => idx.saturating_sub(1),
}
}
fn format_content_hits(
path: &Path,
content: &str,
regex: &Regex,
multiline: bool,
before: usize,
after: usize,
line_numbers: bool,
) -> Vec<String> {
let lines: Vec<&str> = content.lines().collect();
if lines.is_empty() {
return Vec::new();
}
let mut selected_lines = BTreeSet::new();
if multiline {
let mut line_starts = vec![0usize];
for (idx, byte) in content.bytes().enumerate() {
if byte == b'\n' {
line_starts.push(idx + 1);
}
}
for mat in regex.find_iter(content) {
let start_line = Self::byte_to_line(&line_starts, mat.start());
let end_line = Self::byte_to_line(&line_starts, mat.end().saturating_sub(1));
let range_start = start_line.saturating_sub(before);
let range_end = (end_line + after).min(lines.len().saturating_sub(1));
for line_idx in range_start..=range_end {
selected_lines.insert(line_idx);
}
}
} else {
for (idx, line) in lines.iter().enumerate() {
if regex.is_match(line) {
let range_start = idx.saturating_sub(before);
let range_end = (idx + after).min(lines.len().saturating_sub(1));
for line_idx in range_start..=range_end {
selected_lines.insert(line_idx);
}
}
}
}
selected_lines
.into_iter()
.map(|idx| {
let display_path = bamboo_config::paths::path_to_display_string(path);
if line_numbers {
format!("{}:{}:{}", display_path, idx + 1, lines[idx])
} else {
format!("{}:{}", display_path, lines[idx])
}
})
.collect()
}
fn resolve_search_root(path: Option<&str>, cwd: &Path) -> PathBuf {
match path {
Some(path) => {
let candidate = PathBuf::from(path);
if candidate.is_absolute() {
candidate
} else {
cwd.join(candidate)
}
}
None => cwd.to_path_buf(),
}
}
fn validate_scope(
args: &GrepArgs,
output_mode: OutputMode,
multiline: bool,
cwd: &Path,
) -> Result<(), ToolError> {
if args.include_ignored && args.path.is_none() {
return Err(ToolError::InvalidArguments(
"include_ignored requires an explicit path.".to_string(),
));
}
if matches!(output_mode, OutputMode::Content)
&& args.path.is_none()
&& args.glob.is_none()
&& args.r#type.is_none()
{
return Err(ToolError::InvalidArguments(
SEARCH_SCOPE_TOO_BROAD_ERROR.to_string(),
));
}
if multiline {
let Some(path) = args.path.as_deref() else {
return Err(ToolError::InvalidArguments(
MULTILINE_REQUIRES_NARROWED_PATH_ERROR.to_string(),
));
};
let resolved = Self::resolve_search_root(Some(path), cwd);
if resolved.is_dir() {
if let (Ok(resolved_canonical), Ok(cwd_canonical)) =
(resolved.canonicalize(), cwd.canonicalize())
{
if resolved_canonical == cwd_canonical {
return Err(ToolError::InvalidArguments(
MULTILINE_REQUIRES_NARROWED_PATH_ERROR.to_string(),
));
}
}
}
}
Ok(())
}
}
impl Default for GrepTool {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl Tool for GrepTool {
fn name(&self) -> &str {
"Grep"
}
fn description(&self) -> &str {
"Search file contents using ripgrep-style regex parameters. Directory searches respect repository .gitignore rules, including parent rules up to the Git root; non-repository searches do not apply ignore rules. Hidden files remain visible; global Git ignores, .ignore and .git/info/exclude are not applied. Start with files_with_matches or a narrowed path/glob/type before using content or multiline mode."
}
fn classify(&self, _args: &serde_json::Value) -> ToolClass {
ToolClass::READONLY_PARALLEL
}
fn parameters_schema(&self) -> serde_json::Value {
parameter_schema::for_arguments::<GrepArgs>()
}
async fn invoke(
&self,
args: serde_json::Value,
ctx: ToolCtx,
) -> Result<ToolOutcome, ToolError> {
let parsed: GrepArgs = serde_json::from_value(args)
.map_err(|e| ToolError::InvalidArguments(format!("Invalid Grep args: {}", e)))?;
let cwd = workspace_state::workspace_or_process_cwd(ctx.session_id());
let root = Self::resolve_search_root(parsed.path.as_deref(), &cwd);
let output_mode = parsed.output_mode.unwrap_or_default();
let context = parsed.context.unwrap_or(0);
let before = parsed.before.unwrap_or(context);
let after = parsed.after.unwrap_or(context);
let line_numbers = parsed.line_numbers.unwrap_or(false);
let case_insensitive = parsed.case_insensitive.unwrap_or(false);
let multiline = parsed.multiline.unwrap_or(false);
let head_limit = parsed.head_limit.unwrap_or(DEFAULT_HEAD_LIMIT);
Self::validate_scope(&parsed, output_mode, multiline, &cwd)?;
let regex = Self::compile_regex(&parsed.pattern, case_insensitive, multiline)?;
let glob_filter = Self::compile_glob(parsed.glob.as_deref())?;
let discovery_root = root.clone();
let type_filter = parsed.r#type.clone();
let include_ignored = parsed.include_ignored;
let files = tokio::task::spawn_blocking(move || {
if discovery_root.is_file() {
Ok(vec![discovery_root])
} else if discovery_root.is_dir() {
Ok(Self::collect_files(
&discovery_root,
type_filter.as_deref(),
include_ignored,
))
} else {
Err(ToolError::Execution(format!(
"Path does not exist: {}",
discovery_root.display()
)))
}
})
.await
.map_err(|error| {
ToolError::Execution(format!("Failed to discover search files: {}", error))
})??;
let mut matched_files = Vec::new();
let mut count_rows = Vec::new();
let mut content_rows = Vec::new();
let mut total_matches = 0usize;
let mut partial = false;
for file in files {
if let Some(filter) = &glob_filter {
let relative = file.strip_prefix(&root).unwrap_or(&file);
if !filter.is_match(relative) && !filter.is_match(&file) {
continue;
}
}
let Ok(metadata) = tokio::fs::metadata(&file).await else {
continue;
};
if metadata.len() > MAX_FILE_BYTES {
continue;
}
let Ok(content) = tokio::fs::read_to_string(&file).await else {
continue;
};
if content.contains('\0') {
continue;
}
let match_count = if multiline {
regex.find_iter(&content).count()
} else {
content.lines().filter(|line| regex.is_match(line)).count()
};
if match_count == 0 {
continue;
}
total_matches = total_matches.saturating_add(match_count);
if total_matches > MAX_MATCHES {
return Err(ToolError::Execution(RESULT_TOO_LARGE_ERROR.to_string()));
}
matched_files.push(bamboo_config::paths::path_to_display_string(&file));
count_rows.push(format!(
"{}:{}",
bamboo_config::paths::path_to_display_string(&file),
match_count
));
if matches!(output_mode, OutputMode::Content) {
content_rows.extend(Self::format_content_hits(
&file,
&content,
®ex,
multiline,
before,
after,
line_numbers,
));
if content_rows.len() >= head_limit {
content_rows.truncate(head_limit);
partial = true;
break;
}
}
if matches!(
output_mode,
OutputMode::FilesWithMatches | OutputMode::Count
) && matched_files.len() >= head_limit
{
partial = true;
break;
}
}
let mut result_lines = match output_mode {
OutputMode::FilesWithMatches => matched_files,
OutputMode::Count => count_rows,
OutputMode::Content => content_rows,
};
if result_lines.len() > head_limit {
result_lines.truncate(head_limit);
partial = true;
}
if partial {
result_lines
.push("[PARTIAL] Output was truncated. Narrow path/pattern and retry.".to_string());
}
let result = result_lines.join("\n");
if result.len() > MAX_RESULT_BYTES {
return Err(ToolError::Execution(RESULT_TOO_LARGE_ERROR.to_string()));
}
Ok(ToolOutcome::Completed(ToolResult {
success: true,
result,
display_preference: Some("Collapsible".to_string()),
images: Vec::new(),
}))
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::sync::{mpsc, Mutex, OnceLock};
use std::time::Duration;
use tokio::sync::oneshot;
struct DiscoveryPause {
started: oneshot::Sender<()>,
release: mpsc::Receiver<()>,
}
fn discovery_pauses() -> &'static Mutex<HashMap<PathBuf, DiscoveryPause>> {
static PAUSES: OnceLock<Mutex<HashMap<PathBuf, DiscoveryPause>>> = OnceLock::new();
PAUSES.get_or_init(Mutex::default)
}
pub(super) fn pause_directory_discovery(path: &Path) {
let pause = discovery_pauses().lock().unwrap().remove(path);
if let Some(pause) = pause {
pause.started.send(()).unwrap();
pause
.release
.recv_timeout(Duration::from_secs(5))
.expect("directory discovery blocked the async runtime");
}
}
fn pause_next_discovery(path: &Path) -> (oneshot::Receiver<()>, mpsc::Sender<()>) {
let (started, observed) = oneshot::channel();
let (release, wait) = mpsc::channel();
assert!(discovery_pauses()
.lock()
.unwrap()
.insert(
path.to_owned(),
DiscoveryPause {
started,
release: wait,
},
)
.is_none());
(observed, release)
}
#[tokio::test(flavor = "current_thread", start_paused = true)]
async fn grep_directory_discovery_keeps_async_heartbeat_running() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("match.rs"), "needle\n").unwrap();
let mut heartbeat_timer = tokio::time::interval(Duration::from_secs(5));
heartbeat_timer.tick().await;
let (started, release) = pause_next_discovery(dir.path());
let args = json!({"pattern": "needle", "path": dir.path()});
let search = tokio::spawn(async move { run(&GrepTool::new(), args).await });
started.await.unwrap();
let heartbeat = tokio::spawn(async move { heartbeat_timer.tick().await });
tokio::time::advance(Duration::from_secs(5)).await;
heartbeat.await.unwrap();
assert!(
!search.is_finished(),
"heartbeat must run while directory discovery is still blocked"
);
release.send(()).unwrap();
let result = search.await.unwrap().unwrap();
assert!(result.success);
assert!(result.result.contains("match.rs"));
}
#[tokio::test(flavor = "current_thread")]
async fn grep_can_be_cancelled_while_directory_discovery_is_blocked() {
let dir = tempfile::tempdir().unwrap();
let (started, release) = pause_next_discovery(dir.path());
let args = json!({"pattern": "needle", "path": dir.path()});
let search = tokio::spawn(async move { run(&GrepTool::new(), args).await });
started.await.unwrap();
search.abort();
let cancelled = search.await.unwrap_err().is_cancelled();
release.send(()).unwrap();
assert!(cancelled);
}
#[tokio::test]
async fn grep_missing_path_preserves_execution_error() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("missing");
let error = run(
&GrepTool::new(),
json!({"pattern": "needle", "path": missing}),
)
.await
.unwrap_err();
match error {
ToolError::Execution(message) => {
assert_eq!(
message,
format!("Path does not exist: {}", missing.display())
);
}
other => panic!("expected execution error, got {other}"),
}
}
#[test]
fn grep_args_preserve_flag_names_and_all_values() {
let parsed: GrepArgs = serde_json::from_value(json!({
"pattern": "needle",
"path": "src",
"glob": "**/*.rs",
"output_mode": "content",
"-B": 1,
"-A": 2,
"-C": 3,
"-n": true,
"-i": false,
"type": "rust",
"head_limit": 0,
"multiline": false,
"include_ignored": true,
"before": 99,
"after": 99,
"context": 99,
"line_numbers": false,
"case_insensitive": true
}))
.unwrap();
assert_eq!(parsed.pattern, "needle");
assert_eq!(parsed.path.as_deref(), Some("src"));
assert_eq!(parsed.glob.as_deref(), Some("**/*.rs"));
assert!(matches!(parsed.output_mode, Some(OutputMode::Content)));
assert_eq!(parsed.before, Some(1));
assert_eq!(parsed.after, Some(2));
assert_eq!(parsed.context, Some(3));
assert_eq!(parsed.line_numbers, Some(true));
assert_eq!(parsed.case_insensitive, Some(false));
assert_eq!(parsed.r#type.as_deref(), Some("rust"));
assert_eq!(parsed.head_limit, Some(0));
assert_eq!(parsed.multiline, Some(false));
assert!(parsed.include_ignored);
}
#[test]
fn grep_args_preserve_defaults_nulls_and_unknown_fields() {
for value in [
json!({"pattern": "needle"}),
json!({
"pattern": "needle", "before": 1, "after": 1, "context": 1,
"line_numbers": true, "case_insensitive": true, "unknown": true
}),
json!({
"pattern": "needle", "path": null, "glob": null,
"output_mode": null, "-B": null, "-A": null, "-C": null,
"-n": null, "-i": null, "type": null, "head_limit": null,
"multiline": null
}),
] {
let parsed: GrepArgs = serde_json::from_value(value).unwrap();
assert_eq!(parsed.pattern, "needle");
assert!(parsed.path.is_none());
assert!(parsed.glob.is_none());
assert!(parsed.output_mode.is_none());
assert!(parsed.before.is_none());
assert!(parsed.after.is_none());
assert!(parsed.context.is_none());
assert!(parsed.line_numbers.is_none());
assert!(parsed.case_insensitive.is_none());
assert!(parsed.r#type.is_none());
assert!(parsed.head_limit.is_none());
assert!(parsed.multiline.is_none());
assert!(!parsed.include_ignored);
assert!(matches!(
parsed.output_mode.unwrap_or_default(),
OutputMode::FilesWithMatches
));
}
}
#[test]
fn grep_args_preserve_output_mode_spellings() {
for (name, expected) in [
("content", OutputMode::Content),
("files_with_matches", OutputMode::FilesWithMatches),
("count", OutputMode::Count),
] {
let parsed: GrepArgs =
serde_json::from_value(json!({"pattern": "needle", "output_mode": name})).unwrap();
assert_eq!(
std::mem::discriminant(&parsed.output_mode.unwrap()),
std::mem::discriminant(&expected)
);
}
}
#[test]
fn grep_args_preserve_invalid_argument_rejection() {
for value in [
json!({}),
json!({"pattern": null}),
json!({"pattern": 1}),
json!({"pattern": "needle", "include_ignored": null}),
json!({"pattern": "needle", "output_mode": "FilesWithMatches"}),
json!({"pattern": "needle", "output_mode": "unknown"}),
json!({"pattern": "needle", "output_mode": 1}),
] {
assert!(serde_json::from_value::<GrepArgs>(value).is_err());
}
for key in ["-B", "-A", "-C", "head_limit"] {
for invalid in [json!(-1), json!(1.5), json!("1")] {
let mut value = json!({"pattern": "needle"});
value[key] = invalid;
assert!(serde_json::from_value::<GrepArgs>(value).is_err(), "{key}");
}
}
for key in ["-n", "-i", "multiline", "include_ignored"] {
let mut value = json!({"pattern": "needle"});
value[key] = json!(1);
assert!(serde_json::from_value::<GrepArgs>(value).is_err(), "{key}");
}
}
async fn run(tool: &GrepTool, args: serde_json::Value) -> Result<ToolResult, ToolError> {
match tool.invoke(args, ToolCtx::none("t")).await? {
ToolOutcome::Completed(r) => Ok(r),
_ => panic!("expected Completed"),
}
}
fn result_lines(result: &ToolResult) -> Vec<&str> {
result
.result
.lines()
.filter(|line| !line.is_empty())
.collect()
}
fn non_partial_lines(result: &ToolResult) -> Vec<&str> {
result_lines(result)
.into_iter()
.filter(|line| !line.starts_with("[PARTIAL]"))
.collect()
}
#[tokio::test]
async fn grep_defaults_to_files_with_matches() {
let dir = tempfile::tempdir().unwrap();
let file_hit = dir.path().join("match.rs");
let file_miss = dir.path().join("miss.txt");
tokio::fs::write(&file_hit, "let value = 1;\nneedle\n")
.await
.unwrap();
tokio::fs::write(&file_miss, "nothing to see\n")
.await
.unwrap();
let tool = GrepTool::new();
let result = run(
&tool,
json!({
"pattern": "needle",
"path": dir.path()
}),
)
.await
.unwrap();
assert!(result.success);
let lines = result_lines(&result);
assert_eq!(lines.len(), 1);
assert!(lines[0].contains("match.rs"));
}
#[tokio::test]
async fn grep_skips_project_worktree_checkout() {
let dir = tempfile::tempdir().unwrap();
let kept = dir.path().join("src/kept.txt");
let duplicate = dir.path().join(".bamboo/worktree/child/duplicate.txt");
tokio::fs::create_dir_all(kept.parent().unwrap())
.await
.unwrap();
tokio::fs::create_dir_all(duplicate.parent().unwrap())
.await
.unwrap();
tokio::fs::write(&kept, "needle").await.unwrap();
tokio::fs::write(&duplicate, "needle").await.unwrap();
let result = run(
&GrepTool::new(),
json!({"pattern": "needle", "path": dir.path()}),
)
.await
.unwrap();
assert!(result.result.contains("kept.txt"));
assert!(!result.result.contains("duplicate.txt"));
}
#[tokio::test]
async fn grep_content_mode_supports_context_and_line_numbers() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("content.txt");
tokio::fs::write(&file, "one\ntwo\nneedle\nfour\nfive\n")
.await
.unwrap();
let tool = GrepTool::new();
let result = run(
&tool,
json!({
"pattern": "needle",
"path": file,
"output_mode": "content",
"-C": 1,
"-n": true
}),
)
.await
.unwrap();
let output = result.result;
assert!(output.contains(":2:two"));
assert!(output.contains(":3:needle"));
assert!(output.contains(":4:four"));
assert!(!output.contains(":1:one"));
assert!(!output.contains(":5:five"));
}
#[tokio::test]
async fn grep_content_flags_preserve_context_override_and_case_matching() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("flags.txt");
tokio::fs::write(&file, "one\ntwo\nNEEDLE\nfour\nfive\n")
.await
.unwrap();
let result = run(
&GrepTool::new(),
json!({
"pattern": "needle", "path": file, "output_mode": "content",
"-C": 1, "-B": 2, "-A": 0, "-n": true, "-i": true
}),
)
.await
.unwrap();
let lines = result_lines(&result);
assert_eq!(lines.len(), 3);
assert!(lines[0].ends_with(":1:one"));
assert!(lines[1].ends_with(":2:two"));
assert!(lines[2].ends_with(":3:NEEDLE"));
assert!(!result.result.contains(":4:four"));
}
#[tokio::test]
async fn grep_count_mode_respects_type_filter_and_head_limit() {
let dir = tempfile::tempdir().unwrap();
let file_rs_a = dir.path().join("a.rs");
let file_rs_b = dir.path().join("b.rs");
let file_txt = dir.path().join("c.txt");
tokio::fs::write(&file_rs_a, "foo\nfoo\n").await.unwrap();
tokio::fs::write(&file_rs_b, "foo\n").await.unwrap();
tokio::fs::write(&file_txt, "foo\n").await.unwrap();
let tool = GrepTool::new();
let result = run(
&tool,
json!({
"pattern": "foo",
"path": dir.path(),
"output_mode": "count",
"type": "rust",
"head_limit": 1
}),
)
.await
.unwrap();
let lines = non_partial_lines(&result);
assert_eq!(lines.len(), 1);
assert!(lines[0].contains(".rs:"));
assert!(!lines[0].contains("c.txt"));
assert!(result.result.contains("[PARTIAL]"));
}
#[tokio::test]
async fn grep_multiline_and_case_insensitive_work_with_glob_filter() {
let dir = tempfile::tempdir().unwrap();
let file_one = dir.path().join("one.rs");
let file_two = dir.path().join("two.rs");
tokio::fs::write(&file_one, "Hello\nWORLD\n").await.unwrap();
tokio::fs::write(&file_two, "Hello\nplanet\n")
.await
.unwrap();
let tool = GrepTool::new();
let result = run(
&tool,
json!({
"pattern": "hello\\s+world",
"path": dir.path(),
"glob": "**/one.rs",
"-i": true,
"multiline": true
}),
)
.await
.unwrap();
let output = result.result;
assert!(output.contains("one.rs"));
assert!(!output.contains("two.rs"));
}
#[tokio::test]
async fn grep_content_mode_requires_scope_hint() {
let tool = GrepTool::new();
let error = run(
&tool,
json!({
"pattern": "needle",
"output_mode": "content"
}),
)
.await
.expect_err("content mode without scope should fail");
assert!(matches!(error, ToolError::InvalidArguments(_)));
assert!(error.to_string().contains(SEARCH_SCOPE_TOO_BROAD_ERROR));
}
#[tokio::test]
async fn grep_multiline_requires_explicit_narrowed_path() {
let tool = GrepTool::new();
let error = run(
&tool,
json!({
"pattern": "a\\s+b",
"multiline": true
}),
)
.await
.expect_err("multiline without path should fail");
assert!(matches!(error, ToolError::InvalidArguments(_)));
assert!(error
.to_string()
.contains(MULTILINE_REQUIRES_NARROWED_PATH_ERROR));
let cwd = std::env::current_dir().unwrap();
let error = run(
&tool,
json!({
"pattern": "a\\s+b",
"multiline": true,
"path": cwd
}),
)
.await
.expect_err("multiline at workspace root should fail");
assert!(matches!(error, ToolError::InvalidArguments(_)));
assert!(error
.to_string()
.contains(MULTILINE_REQUIRES_NARROWED_PATH_ERROR));
}
#[tokio::test]
async fn grep_defaults_head_limit_to_200() {
let dir = tempfile::tempdir().unwrap();
for idx in 0..260 {
let file = dir.path().join(format!("file-{idx}.txt"));
tokio::fs::write(&file, "needle\n").await.unwrap();
}
let tool = GrepTool::new();
let result = run(
&tool,
json!({
"pattern": "needle",
"path": dir.path()
}),
)
.await
.unwrap();
let lines = non_partial_lines(&result);
assert_eq!(lines.len(), 200);
assert!(result.result.contains("[PARTIAL]"));
}
#[tokio::test]
async fn grep_rejects_excessive_match_volume() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("huge.txt");
let mut content = String::new();
for _ in 0..(MAX_MATCHES + 1) {
content.push_str("needle\n");
}
tokio::fs::write(&file, content).await.unwrap();
let tool = GrepTool::new();
let error = run(
&tool,
json!({
"pattern": "needle",
"path": file
}),
)
.await
.expect_err("should reject oversized results");
assert!(matches!(error, ToolError::Execution(_)));
assert!(error.to_string().contains(RESULT_TOO_LARGE_ERROR));
}
#[tokio::test]
async fn grep_ignored_file_opt_out_keeps_type_glob_and_file_size_limits() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join(".git")).unwrap();
tokio::fs::write(dir.path().join(".gitignore"), "*\n")
.await
.unwrap();
tokio::fs::write(dir.path().join("small.rs"), "needle\n")
.await
.unwrap();
tokio::fs::write(dir.path().join("other.rs"), "needle\n")
.await
.unwrap();
tokio::fs::write(dir.path().join("small.txt"), "needle\n")
.await
.unwrap();
let mut oversized = vec![b'x'; MAX_FILE_BYTES as usize + 1];
oversized[..6].copy_from_slice(b"needle");
tokio::fs::write(dir.path().join("large.rs"), oversized)
.await
.unwrap();
let result = run(
&GrepTool::new(),
json!({
"pattern": "needle",
"path": dir.path(),
"include_ignored": true,
"type": "rust",
"glob": "**/{small,large}.rs"
}),
)
.await
.unwrap();
let lines = result_lines(&result);
assert_eq!(lines.len(), 1);
assert!(lines[0].contains("small.rs"));
}
}