1use async_trait::async_trait;
2use bamboo_agent_core::{Tool, ToolClass, ToolCtx, ToolError, ToolOutcome, ToolResult};
3use globset::{GlobBuilder, GlobSet};
4use regex::{Regex, RegexBuilder};
5use serde::Deserialize;
6use std::collections::{BTreeSet, HashMap};
7use std::path::{Path, PathBuf};
8
9use super::{parameter_schema, search_traversal, workspace_state};
10
11const DEFAULT_HEAD_LIMIT: usize = 200;
12const MAX_RESULT_BYTES: usize = 256 * 1024;
13const MAX_MATCHES: usize = 2_000;
14const MAX_SCANNED_FILES: usize = 50_000;
15const MAX_FILE_BYTES: u64 = 2 * 1024 * 1024;
16const SEARCH_SCOPE_TOO_BROAD_ERROR: &str =
17 "Search scope too broad. Add path/glob/type or reduce pattern.";
18const MULTILINE_REQUIRES_NARROWED_PATH_ERROR: &str = "Multiline grep requires narrowed path.";
19const RESULT_TOO_LARGE_ERROR: &str = "Result too large; refine query and retry.";
20
21#[derive(Debug, Deserialize, Clone, Copy, Default, schemars::JsonSchema)]
22#[serde(rename_all = "snake_case")]
23#[schemars(inline)]
24enum OutputMode {
25 Content,
26 #[default]
27 FilesWithMatches,
28 Count,
29}
30
31#[derive(Debug, Deserialize, schemars::JsonSchema)]
32#[schemars(deny_unknown_fields)]
33struct GrepArgs {
34 pattern: String,
36 #[serde(default)]
38 #[schemars(with = "String", skip_serializing_if = "Option::is_none")]
39 path: Option<String>,
40 #[serde(default)]
42 #[schemars(with = "String", skip_serializing_if = "Option::is_none")]
43 glob: Option<String>,
44 #[serde(default)]
46 #[schemars(with = "OutputMode", skip_serializing_if = "Option::is_none")]
47 output_mode: Option<OutputMode>,
48 #[serde(rename = "-B", default)]
50 #[schemars(
51 schema_with = "parameter_schema::number",
52 skip_serializing_if = "Option::is_none"
53 )]
54 before: Option<usize>,
55 #[serde(rename = "-A", default)]
57 #[schemars(
58 schema_with = "parameter_schema::number",
59 skip_serializing_if = "Option::is_none"
60 )]
61 after: Option<usize>,
62 #[serde(rename = "-C", default)]
64 #[schemars(
65 schema_with = "parameter_schema::number",
66 skip_serializing_if = "Option::is_none"
67 )]
68 context: Option<usize>,
69 #[serde(rename = "-n", default)]
71 #[schemars(with = "bool", skip_serializing_if = "Option::is_none")]
72 line_numbers: Option<bool>,
73 #[serde(rename = "-i", default)]
75 #[schemars(with = "bool", skip_serializing_if = "Option::is_none")]
76 case_insensitive: Option<bool>,
77 #[serde(default)]
79 #[schemars(with = "String", skip_serializing_if = "Option::is_none")]
80 r#type: Option<String>,
81 #[serde(default)]
83 #[schemars(
84 schema_with = "parameter_schema::number",
85 skip_serializing_if = "Option::is_none"
86 )]
87 head_limit: Option<usize>,
88 #[serde(default)]
90 #[schemars(with = "bool", skip_serializing_if = "Option::is_none")]
91 multiline: Option<bool>,
92 #[serde(default)]
94 include_ignored: bool,
95}
96
97pub struct GrepTool;
98
99impl GrepTool {
100 pub fn new() -> Self {
101 Self
102 }
103
104 fn extension_map() -> HashMap<&'static str, &'static [&'static str]> {
105 HashMap::from([
106 ("js", &["js", "mjs", "cjs"] as &[_]),
107 ("ts", &["ts", "tsx"]),
108 ("py", &["py"]),
109 ("rust", &["rs"]),
110 ("go", &["go"]),
111 ("java", &["java"]),
112 ("cpp", &["cc", "cpp", "cxx", "hpp", "h"]),
113 ("c", &["c", "h"]),
114 ("json", &["json"]),
115 ("yaml", &["yaml", "yml"]),
116 ("toml", &["toml"]),
117 ("md", &["md", "markdown"]),
118 ])
119 }
120
121 fn collect_files(
122 base: &Path,
123 type_filter: Option<&str>,
124 include_ignored: bool,
125 ) -> Vec<PathBuf> {
126 #[cfg(test)]
127 tests::pause_directory_discovery(base);
128
129 let ext_map = Self::extension_map();
130 let allowed_ext = type_filter.and_then(|name| ext_map.get(name).copied());
131
132 let mut files = Vec::new();
133 for entry in search_traversal::walk(base, include_ignored).filter_map(|entry| entry.ok()) {
134 if !entry.file_type().is_some_and(|kind| kind.is_file()) {
135 continue;
136 }
137 if files.len() >= MAX_SCANNED_FILES {
138 break;
139 }
140 let path = entry.path();
141
142 if let Some(extensions) = allowed_ext {
143 let ext = path
144 .extension()
145 .and_then(|v| v.to_str())
146 .unwrap_or_default();
147 if !extensions.iter().any(|candidate| candidate == &ext) {
148 continue;
149 }
150 }
151
152 files.push(path.to_path_buf());
153 }
154
155 files
156 }
157
158 fn compile_glob(glob: Option<&str>) -> Result<Option<GlobSet>, ToolError> {
159 let Some(pattern) = glob else {
160 return Ok(None);
161 };
162
163 let mut builder = globset::GlobSetBuilder::new();
164 let glob = GlobBuilder::new(pattern)
165 .literal_separator(false)
166 .build()
167 .map_err(|e| ToolError::InvalidArguments(format!("Invalid glob pattern: {}", e)))?;
168 builder.add(glob);
169 builder
170 .build()
171 .map(Some)
172 .map_err(|e| ToolError::Execution(format!("Failed to compile glob: {}", e)))
173 }
174
175 fn compile_regex(
176 pattern: &str,
177 case_insensitive: bool,
178 multiline: bool,
179 ) -> Result<Regex, ToolError> {
180 let mut builder = RegexBuilder::new(pattern);
181 builder.case_insensitive(case_insensitive);
182 builder.dot_matches_new_line(multiline);
183 builder.multi_line(multiline);
184 builder
185 .build()
186 .map_err(|e| ToolError::InvalidArguments(format!("Invalid regex pattern: {}", e)))
187 }
188
189 fn byte_to_line(line_starts: &[usize], byte: usize) -> usize {
190 match line_starts.binary_search(&byte) {
191 Ok(idx) => idx,
192 Err(idx) => idx.saturating_sub(1),
193 }
194 }
195
196 fn format_content_hits(
197 path: &Path,
198 content: &str,
199 regex: &Regex,
200 multiline: bool,
201 before: usize,
202 after: usize,
203 line_numbers: bool,
204 ) -> Vec<String> {
205 let lines: Vec<&str> = content.lines().collect();
206 if lines.is_empty() {
207 return Vec::new();
208 }
209
210 let mut selected_lines = BTreeSet::new();
211
212 if multiline {
213 let mut line_starts = vec![0usize];
214 for (idx, byte) in content.bytes().enumerate() {
215 if byte == b'\n' {
216 line_starts.push(idx + 1);
217 }
218 }
219
220 for mat in regex.find_iter(content) {
221 let start_line = Self::byte_to_line(&line_starts, mat.start());
222 let end_line = Self::byte_to_line(&line_starts, mat.end().saturating_sub(1));
223 let range_start = start_line.saturating_sub(before);
224 let range_end = (end_line + after).min(lines.len().saturating_sub(1));
225 for line_idx in range_start..=range_end {
226 selected_lines.insert(line_idx);
227 }
228 }
229 } else {
230 for (idx, line) in lines.iter().enumerate() {
231 if regex.is_match(line) {
232 let range_start = idx.saturating_sub(before);
233 let range_end = (idx + after).min(lines.len().saturating_sub(1));
234 for line_idx in range_start..=range_end {
235 selected_lines.insert(line_idx);
236 }
237 }
238 }
239 }
240
241 selected_lines
242 .into_iter()
243 .map(|idx| {
244 let display_path = bamboo_config::paths::path_to_display_string(path);
245 if line_numbers {
246 format!("{}:{}:{}", display_path, idx + 1, lines[idx])
247 } else {
248 format!("{}:{}", display_path, lines[idx])
249 }
250 })
251 .collect()
252 }
253
254 fn resolve_search_root(path: Option<&str>, cwd: &Path) -> PathBuf {
255 match path {
256 Some(path) => {
257 let candidate = PathBuf::from(path);
258 if candidate.is_absolute() {
259 candidate
260 } else {
261 cwd.join(candidate)
262 }
263 }
264 None => cwd.to_path_buf(),
265 }
266 }
267
268 fn validate_scope(
269 args: &GrepArgs,
270 output_mode: OutputMode,
271 multiline: bool,
272 cwd: &Path,
273 ) -> Result<(), ToolError> {
274 if args.include_ignored && args.path.is_none() {
275 return Err(ToolError::InvalidArguments(
276 "include_ignored requires an explicit path.".to_string(),
277 ));
278 }
279 if matches!(output_mode, OutputMode::Content)
280 && args.path.is_none()
281 && args.glob.is_none()
282 && args.r#type.is_none()
283 {
284 return Err(ToolError::InvalidArguments(
285 SEARCH_SCOPE_TOO_BROAD_ERROR.to_string(),
286 ));
287 }
288
289 if multiline {
290 let Some(path) = args.path.as_deref() else {
291 return Err(ToolError::InvalidArguments(
292 MULTILINE_REQUIRES_NARROWED_PATH_ERROR.to_string(),
293 ));
294 };
295
296 let resolved = Self::resolve_search_root(Some(path), cwd);
297 if resolved.is_dir() {
298 if let (Ok(resolved_canonical), Ok(cwd_canonical)) =
299 (resolved.canonicalize(), cwd.canonicalize())
300 {
301 if resolved_canonical == cwd_canonical {
302 return Err(ToolError::InvalidArguments(
303 MULTILINE_REQUIRES_NARROWED_PATH_ERROR.to_string(),
304 ));
305 }
306 }
307 }
308 }
309
310 Ok(())
311 }
312}
313
314impl Default for GrepTool {
315 fn default() -> Self {
316 Self::new()
317 }
318}
319
320#[async_trait]
321impl Tool for GrepTool {
322 fn name(&self) -> &str {
323 "Grep"
324 }
325
326 fn description(&self) -> &str {
327 "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."
328 }
329
330 fn classify(&self, _args: &serde_json::Value) -> ToolClass {
331 ToolClass::READONLY_PARALLEL
332 }
333
334 fn parameters_schema(&self) -> serde_json::Value {
335 parameter_schema::for_arguments::<GrepArgs>()
336 }
337
338 async fn invoke(
339 &self,
340 args: serde_json::Value,
341 ctx: ToolCtx,
342 ) -> Result<ToolOutcome, ToolError> {
343 let parsed: GrepArgs = serde_json::from_value(args)
344 .map_err(|e| ToolError::InvalidArguments(format!("Invalid Grep args: {}", e)))?;
345
346 let cwd = workspace_state::workspace_or_process_cwd(ctx.session_id());
347 let root = Self::resolve_search_root(parsed.path.as_deref(), &cwd);
348
349 let output_mode = parsed.output_mode.unwrap_or_default();
350 let context = parsed.context.unwrap_or(0);
351 let before = parsed.before.unwrap_or(context);
352 let after = parsed.after.unwrap_or(context);
353 let line_numbers = parsed.line_numbers.unwrap_or(false);
354 let case_insensitive = parsed.case_insensitive.unwrap_or(false);
355 let multiline = parsed.multiline.unwrap_or(false);
356 let head_limit = parsed.head_limit.unwrap_or(DEFAULT_HEAD_LIMIT);
357
358 Self::validate_scope(&parsed, output_mode, multiline, &cwd)?;
359
360 let regex = Self::compile_regex(&parsed.pattern, case_insensitive, multiline)?;
361 let glob_filter = Self::compile_glob(parsed.glob.as_deref())?;
362
363 let discovery_root = root.clone();
364 let type_filter = parsed.r#type.clone();
365 let include_ignored = parsed.include_ignored;
366 let files = tokio::task::spawn_blocking(move || {
370 if discovery_root.is_file() {
371 Ok(vec![discovery_root])
372 } else if discovery_root.is_dir() {
373 Ok(Self::collect_files(
374 &discovery_root,
375 type_filter.as_deref(),
376 include_ignored,
377 ))
378 } else {
379 Err(ToolError::Execution(format!(
380 "Path does not exist: {}",
381 discovery_root.display()
382 )))
383 }
384 })
385 .await
386 .map_err(|error| {
387 ToolError::Execution(format!("Failed to discover search files: {}", error))
388 })??;
389
390 let mut matched_files = Vec::new();
391 let mut count_rows = Vec::new();
392 let mut content_rows = Vec::new();
393 let mut total_matches = 0usize;
394 let mut partial = false;
395
396 for file in files {
397 if let Some(filter) = &glob_filter {
398 let relative = file.strip_prefix(&root).unwrap_or(&file);
399 if !filter.is_match(relative) && !filter.is_match(&file) {
400 continue;
401 }
402 }
403
404 let Ok(metadata) = tokio::fs::metadata(&file).await else {
405 continue;
406 };
407 if metadata.len() > MAX_FILE_BYTES {
408 continue;
409 }
410
411 let Ok(content) = tokio::fs::read_to_string(&file).await else {
412 continue;
413 };
414
415 if content.contains('\0') {
416 continue;
417 }
418
419 let match_count = if multiline {
420 regex.find_iter(&content).count()
421 } else {
422 content.lines().filter(|line| regex.is_match(line)).count()
423 };
424
425 if match_count == 0 {
426 continue;
427 }
428
429 total_matches = total_matches.saturating_add(match_count);
430 if total_matches > MAX_MATCHES {
431 return Err(ToolError::Execution(RESULT_TOO_LARGE_ERROR.to_string()));
432 }
433
434 matched_files.push(bamboo_config::paths::path_to_display_string(&file));
435 count_rows.push(format!(
436 "{}:{}",
437 bamboo_config::paths::path_to_display_string(&file),
438 match_count
439 ));
440
441 if matches!(output_mode, OutputMode::Content) {
442 content_rows.extend(Self::format_content_hits(
443 &file,
444 &content,
445 ®ex,
446 multiline,
447 before,
448 after,
449 line_numbers,
450 ));
451 if content_rows.len() >= head_limit {
452 content_rows.truncate(head_limit);
453 partial = true;
454 break;
455 }
456 }
457
458 if matches!(
459 output_mode,
460 OutputMode::FilesWithMatches | OutputMode::Count
461 ) && matched_files.len() >= head_limit
462 {
463 partial = true;
464 break;
465 }
466 }
467
468 let mut result_lines = match output_mode {
469 OutputMode::FilesWithMatches => matched_files,
470 OutputMode::Count => count_rows,
471 OutputMode::Content => content_rows,
472 };
473
474 if result_lines.len() > head_limit {
475 result_lines.truncate(head_limit);
476 partial = true;
477 }
478 if partial {
479 result_lines
480 .push("[PARTIAL] Output was truncated. Narrow path/pattern and retry.".to_string());
481 }
482
483 let result = result_lines.join("\n");
484 if result.len() > MAX_RESULT_BYTES {
485 return Err(ToolError::Execution(RESULT_TOO_LARGE_ERROR.to_string()));
486 }
487
488 Ok(ToolOutcome::Completed(ToolResult {
489 success: true,
490 result,
491 display_preference: Some("Collapsible".to_string()),
492 images: Vec::new(),
493 }))
494 }
495}
496
497#[cfg(test)]
498mod tests {
499 use super::*;
500 use serde_json::json;
501 use std::sync::{mpsc, Mutex, OnceLock};
502 use std::time::Duration;
503 use tokio::sync::oneshot;
504
505 struct DiscoveryPause {
506 started: oneshot::Sender<()>,
507 release: mpsc::Receiver<()>,
508 }
509
510 fn discovery_pauses() -> &'static Mutex<HashMap<PathBuf, DiscoveryPause>> {
511 static PAUSES: OnceLock<Mutex<HashMap<PathBuf, DiscoveryPause>>> = OnceLock::new();
512 PAUSES.get_or_init(Mutex::default)
513 }
514
515 pub(super) fn pause_directory_discovery(path: &Path) {
516 let pause = discovery_pauses().lock().unwrap().remove(path);
517 if let Some(pause) = pause {
518 pause.started.send(()).unwrap();
519 pause
522 .release
523 .recv_timeout(Duration::from_secs(5))
524 .expect("directory discovery blocked the async runtime");
525 }
526 }
527
528 fn pause_next_discovery(path: &Path) -> (oneshot::Receiver<()>, mpsc::Sender<()>) {
529 let (started, observed) = oneshot::channel();
530 let (release, wait) = mpsc::channel();
531 assert!(discovery_pauses()
532 .lock()
533 .unwrap()
534 .insert(
535 path.to_owned(),
536 DiscoveryPause {
537 started,
538 release: wait,
539 },
540 )
541 .is_none());
542 (observed, release)
543 }
544
545 #[tokio::test(flavor = "current_thread", start_paused = true)]
546 async fn grep_directory_discovery_keeps_async_heartbeat_running() {
547 let dir = tempfile::tempdir().unwrap();
548 std::fs::write(dir.path().join("match.rs"), "needle\n").unwrap();
549 let mut heartbeat_timer = tokio::time::interval(Duration::from_secs(5));
550 heartbeat_timer.tick().await;
551 let (started, release) = pause_next_discovery(dir.path());
552 let args = json!({"pattern": "needle", "path": dir.path()});
553 let search = tokio::spawn(async move { run(&GrepTool::new(), args).await });
554
555 started.await.unwrap();
556 let heartbeat = tokio::spawn(async move { heartbeat_timer.tick().await });
557 tokio::time::advance(Duration::from_secs(5)).await;
558 heartbeat.await.unwrap();
559 assert!(
560 !search.is_finished(),
561 "heartbeat must run while directory discovery is still blocked"
562 );
563 release.send(()).unwrap();
564
565 let result = search.await.unwrap().unwrap();
566 assert!(result.success);
567 assert!(result.result.contains("match.rs"));
568 }
569
570 #[tokio::test(flavor = "current_thread")]
571 async fn grep_can_be_cancelled_while_directory_discovery_is_blocked() {
572 let dir = tempfile::tempdir().unwrap();
573 let (started, release) = pause_next_discovery(dir.path());
574 let args = json!({"pattern": "needle", "path": dir.path()});
575 let search = tokio::spawn(async move { run(&GrepTool::new(), args).await });
576
577 started.await.unwrap();
578 search.abort();
579 let cancelled = search.await.unwrap_err().is_cancelled();
580 release.send(()).unwrap();
581 assert!(cancelled);
582 }
583
584 #[tokio::test]
585 async fn grep_missing_path_preserves_execution_error() {
586 let dir = tempfile::tempdir().unwrap();
587 let missing = dir.path().join("missing");
588 let error = run(
589 &GrepTool::new(),
590 json!({"pattern": "needle", "path": missing}),
591 )
592 .await
593 .unwrap_err();
594
595 match error {
596 ToolError::Execution(message) => {
597 assert_eq!(
598 message,
599 format!("Path does not exist: {}", missing.display())
600 );
601 }
602 other => panic!("expected execution error, got {other}"),
603 }
604 }
605
606 #[test]
607 fn grep_args_preserve_flag_names_and_all_values() {
608 let parsed: GrepArgs = serde_json::from_value(json!({
609 "pattern": "needle",
610 "path": "src",
611 "glob": "**/*.rs",
612 "output_mode": "content",
613 "-B": 1,
614 "-A": 2,
615 "-C": 3,
616 "-n": true,
617 "-i": false,
618 "type": "rust",
619 "head_limit": 0,
620 "multiline": false,
621 "include_ignored": true,
622 "before": 99,
623 "after": 99,
624 "context": 99,
625 "line_numbers": false,
626 "case_insensitive": true
627 }))
628 .unwrap();
629
630 assert_eq!(parsed.pattern, "needle");
631 assert_eq!(parsed.path.as_deref(), Some("src"));
632 assert_eq!(parsed.glob.as_deref(), Some("**/*.rs"));
633 assert!(matches!(parsed.output_mode, Some(OutputMode::Content)));
634 assert_eq!(parsed.before, Some(1));
635 assert_eq!(parsed.after, Some(2));
636 assert_eq!(parsed.context, Some(3));
637 assert_eq!(parsed.line_numbers, Some(true));
638 assert_eq!(parsed.case_insensitive, Some(false));
639 assert_eq!(parsed.r#type.as_deref(), Some("rust"));
640 assert_eq!(parsed.head_limit, Some(0));
641 assert_eq!(parsed.multiline, Some(false));
642 assert!(parsed.include_ignored);
643 }
644
645 #[test]
646 fn grep_args_preserve_defaults_nulls_and_unknown_fields() {
647 for value in [
648 json!({"pattern": "needle"}),
649 json!({
650 "pattern": "needle", "before": 1, "after": 1, "context": 1,
651 "line_numbers": true, "case_insensitive": true, "unknown": true
652 }),
653 json!({
654 "pattern": "needle", "path": null, "glob": null,
655 "output_mode": null, "-B": null, "-A": null, "-C": null,
656 "-n": null, "-i": null, "type": null, "head_limit": null,
657 "multiline": null
658 }),
659 ] {
660 let parsed: GrepArgs = serde_json::from_value(value).unwrap();
661 assert_eq!(parsed.pattern, "needle");
662 assert!(parsed.path.is_none());
663 assert!(parsed.glob.is_none());
664 assert!(parsed.output_mode.is_none());
665 assert!(parsed.before.is_none());
666 assert!(parsed.after.is_none());
667 assert!(parsed.context.is_none());
668 assert!(parsed.line_numbers.is_none());
669 assert!(parsed.case_insensitive.is_none());
670 assert!(parsed.r#type.is_none());
671 assert!(parsed.head_limit.is_none());
672 assert!(parsed.multiline.is_none());
673 assert!(!parsed.include_ignored);
674 assert!(matches!(
675 parsed.output_mode.unwrap_or_default(),
676 OutputMode::FilesWithMatches
677 ));
678 }
679 }
680
681 #[test]
682 fn grep_args_preserve_output_mode_spellings() {
683 for (name, expected) in [
684 ("content", OutputMode::Content),
685 ("files_with_matches", OutputMode::FilesWithMatches),
686 ("count", OutputMode::Count),
687 ] {
688 let parsed: GrepArgs =
689 serde_json::from_value(json!({"pattern": "needle", "output_mode": name})).unwrap();
690 assert_eq!(
691 std::mem::discriminant(&parsed.output_mode.unwrap()),
692 std::mem::discriminant(&expected)
693 );
694 }
695 }
696
697 #[test]
698 fn grep_args_preserve_invalid_argument_rejection() {
699 for value in [
700 json!({}),
701 json!({"pattern": null}),
702 json!({"pattern": 1}),
703 json!({"pattern": "needle", "include_ignored": null}),
704 json!({"pattern": "needle", "output_mode": "FilesWithMatches"}),
705 json!({"pattern": "needle", "output_mode": "unknown"}),
706 json!({"pattern": "needle", "output_mode": 1}),
707 ] {
708 assert!(serde_json::from_value::<GrepArgs>(value).is_err());
709 }
710 for key in ["-B", "-A", "-C", "head_limit"] {
711 for invalid in [json!(-1), json!(1.5), json!("1")] {
712 let mut value = json!({"pattern": "needle"});
713 value[key] = invalid;
714 assert!(serde_json::from_value::<GrepArgs>(value).is_err(), "{key}");
715 }
716 }
717 for key in ["-n", "-i", "multiline", "include_ignored"] {
718 let mut value = json!({"pattern": "needle"});
719 value[key] = json!(1);
720 assert!(serde_json::from_value::<GrepArgs>(value).is_err(), "{key}");
721 }
722 }
723
724 async fn run(tool: &GrepTool, args: serde_json::Value) -> Result<ToolResult, ToolError> {
725 match tool.invoke(args, ToolCtx::none("t")).await? {
726 ToolOutcome::Completed(r) => Ok(r),
727 _ => panic!("expected Completed"),
728 }
729 }
730
731 fn result_lines(result: &ToolResult) -> Vec<&str> {
732 result
733 .result
734 .lines()
735 .filter(|line| !line.is_empty())
736 .collect()
737 }
738
739 fn non_partial_lines(result: &ToolResult) -> Vec<&str> {
740 result_lines(result)
741 .into_iter()
742 .filter(|line| !line.starts_with("[PARTIAL]"))
743 .collect()
744 }
745
746 #[tokio::test]
747 async fn grep_defaults_to_files_with_matches() {
748 let dir = tempfile::tempdir().unwrap();
749 let file_hit = dir.path().join("match.rs");
750 let file_miss = dir.path().join("miss.txt");
751 tokio::fs::write(&file_hit, "let value = 1;\nneedle\n")
752 .await
753 .unwrap();
754 tokio::fs::write(&file_miss, "nothing to see\n")
755 .await
756 .unwrap();
757
758 let tool = GrepTool::new();
759 let result = run(
760 &tool,
761 json!({
762 "pattern": "needle",
763 "path": dir.path()
764 }),
765 )
766 .await
767 .unwrap();
768
769 assert!(result.success);
770 let lines = result_lines(&result);
771 assert_eq!(lines.len(), 1);
772 assert!(lines[0].contains("match.rs"));
773 }
774
775 #[tokio::test]
776 async fn grep_skips_project_worktree_checkout() {
777 let dir = tempfile::tempdir().unwrap();
778 let kept = dir.path().join("src/kept.txt");
779 let duplicate = dir.path().join(".bamboo/worktree/child/duplicate.txt");
780 tokio::fs::create_dir_all(kept.parent().unwrap())
781 .await
782 .unwrap();
783 tokio::fs::create_dir_all(duplicate.parent().unwrap())
784 .await
785 .unwrap();
786 tokio::fs::write(&kept, "needle").await.unwrap();
787 tokio::fs::write(&duplicate, "needle").await.unwrap();
788
789 let result = run(
790 &GrepTool::new(),
791 json!({"pattern": "needle", "path": dir.path()}),
792 )
793 .await
794 .unwrap();
795 assert!(result.result.contains("kept.txt"));
796 assert!(!result.result.contains("duplicate.txt"));
797 }
798
799 #[tokio::test]
800 async fn grep_content_mode_supports_context_and_line_numbers() {
801 let dir = tempfile::tempdir().unwrap();
802 let file = dir.path().join("content.txt");
803 tokio::fs::write(&file, "one\ntwo\nneedle\nfour\nfive\n")
804 .await
805 .unwrap();
806
807 let tool = GrepTool::new();
808 let result = run(
809 &tool,
810 json!({
811 "pattern": "needle",
812 "path": file,
813 "output_mode": "content",
814 "-C": 1,
815 "-n": true
816 }),
817 )
818 .await
819 .unwrap();
820
821 let output = result.result;
822 assert!(output.contains(":2:two"));
823 assert!(output.contains(":3:needle"));
824 assert!(output.contains(":4:four"));
825 assert!(!output.contains(":1:one"));
826 assert!(!output.contains(":5:five"));
827 }
828
829 #[tokio::test]
830 async fn grep_content_flags_preserve_context_override_and_case_matching() {
831 let dir = tempfile::tempdir().unwrap();
832 let file = dir.path().join("flags.txt");
833 tokio::fs::write(&file, "one\ntwo\nNEEDLE\nfour\nfive\n")
834 .await
835 .unwrap();
836
837 let result = run(
838 &GrepTool::new(),
839 json!({
840 "pattern": "needle", "path": file, "output_mode": "content",
841 "-C": 1, "-B": 2, "-A": 0, "-n": true, "-i": true
842 }),
843 )
844 .await
845 .unwrap();
846
847 let lines = result_lines(&result);
848 assert_eq!(lines.len(), 3);
849 assert!(lines[0].ends_with(":1:one"));
850 assert!(lines[1].ends_with(":2:two"));
851 assert!(lines[2].ends_with(":3:NEEDLE"));
852 assert!(!result.result.contains(":4:four"));
853 }
854
855 #[tokio::test]
856 async fn grep_count_mode_respects_type_filter_and_head_limit() {
857 let dir = tempfile::tempdir().unwrap();
858 let file_rs_a = dir.path().join("a.rs");
859 let file_rs_b = dir.path().join("b.rs");
860 let file_txt = dir.path().join("c.txt");
861 tokio::fs::write(&file_rs_a, "foo\nfoo\n").await.unwrap();
862 tokio::fs::write(&file_rs_b, "foo\n").await.unwrap();
863 tokio::fs::write(&file_txt, "foo\n").await.unwrap();
864
865 let tool = GrepTool::new();
866 let result = run(
867 &tool,
868 json!({
869 "pattern": "foo",
870 "path": dir.path(),
871 "output_mode": "count",
872 "type": "rust",
873 "head_limit": 1
874 }),
875 )
876 .await
877 .unwrap();
878
879 let lines = non_partial_lines(&result);
880 assert_eq!(lines.len(), 1);
881 assert!(lines[0].contains(".rs:"));
882 assert!(!lines[0].contains("c.txt"));
883 assert!(result.result.contains("[PARTIAL]"));
884 }
885
886 #[tokio::test]
887 async fn grep_multiline_and_case_insensitive_work_with_glob_filter() {
888 let dir = tempfile::tempdir().unwrap();
889 let file_one = dir.path().join("one.rs");
890 let file_two = dir.path().join("two.rs");
891 tokio::fs::write(&file_one, "Hello\nWORLD\n").await.unwrap();
892 tokio::fs::write(&file_two, "Hello\nplanet\n")
893 .await
894 .unwrap();
895
896 let tool = GrepTool::new();
897 let result = run(
898 &tool,
899 json!({
900 "pattern": "hello\\s+world",
901 "path": dir.path(),
902 "glob": "**/one.rs",
903 "-i": true,
904 "multiline": true
905 }),
906 )
907 .await
908 .unwrap();
909
910 let output = result.result;
911 assert!(output.contains("one.rs"));
912 assert!(!output.contains("two.rs"));
913 }
914
915 #[tokio::test]
916 async fn grep_content_mode_requires_scope_hint() {
917 let tool = GrepTool::new();
918 let error = run(
919 &tool,
920 json!({
921 "pattern": "needle",
922 "output_mode": "content"
923 }),
924 )
925 .await
926 .expect_err("content mode without scope should fail");
927
928 assert!(matches!(error, ToolError::InvalidArguments(_)));
929 assert!(error.to_string().contains(SEARCH_SCOPE_TOO_BROAD_ERROR));
930 }
931
932 #[tokio::test]
933 async fn grep_multiline_requires_explicit_narrowed_path() {
934 let tool = GrepTool::new();
935 let error = run(
936 &tool,
937 json!({
938 "pattern": "a\\s+b",
939 "multiline": true
940 }),
941 )
942 .await
943 .expect_err("multiline without path should fail");
944 assert!(matches!(error, ToolError::InvalidArguments(_)));
945 assert!(error
946 .to_string()
947 .contains(MULTILINE_REQUIRES_NARROWED_PATH_ERROR));
948
949 let cwd = std::env::current_dir().unwrap();
950 let error = run(
951 &tool,
952 json!({
953 "pattern": "a\\s+b",
954 "multiline": true,
955 "path": cwd
956 }),
957 )
958 .await
959 .expect_err("multiline at workspace root should fail");
960 assert!(matches!(error, ToolError::InvalidArguments(_)));
961 assert!(error
962 .to_string()
963 .contains(MULTILINE_REQUIRES_NARROWED_PATH_ERROR));
964 }
965
966 #[tokio::test]
967 async fn grep_defaults_head_limit_to_200() {
968 let dir = tempfile::tempdir().unwrap();
969 for idx in 0..260 {
970 let file = dir.path().join(format!("file-{idx}.txt"));
971 tokio::fs::write(&file, "needle\n").await.unwrap();
972 }
973
974 let tool = GrepTool::new();
975 let result = run(
976 &tool,
977 json!({
978 "pattern": "needle",
979 "path": dir.path()
980 }),
981 )
982 .await
983 .unwrap();
984
985 let lines = non_partial_lines(&result);
986 assert_eq!(lines.len(), 200);
987 assert!(result.result.contains("[PARTIAL]"));
988 }
989
990 #[tokio::test]
991 async fn grep_rejects_excessive_match_volume() {
992 let dir = tempfile::tempdir().unwrap();
993 let file = dir.path().join("huge.txt");
994 let mut content = String::new();
995 for _ in 0..(MAX_MATCHES + 1) {
996 content.push_str("needle\n");
997 }
998 tokio::fs::write(&file, content).await.unwrap();
999
1000 let tool = GrepTool::new();
1001 let error = run(
1002 &tool,
1003 json!({
1004 "pattern": "needle",
1005 "path": file
1006 }),
1007 )
1008 .await
1009 .expect_err("should reject oversized results");
1010
1011 assert!(matches!(error, ToolError::Execution(_)));
1012 assert!(error.to_string().contains(RESULT_TOO_LARGE_ERROR));
1013 }
1014
1015 #[tokio::test]
1016 async fn grep_ignored_file_opt_out_keeps_type_glob_and_file_size_limits() {
1017 let dir = tempfile::tempdir().unwrap();
1018 std::fs::create_dir(dir.path().join(".git")).unwrap();
1019 tokio::fs::write(dir.path().join(".gitignore"), "*\n")
1020 .await
1021 .unwrap();
1022 tokio::fs::write(dir.path().join("small.rs"), "needle\n")
1023 .await
1024 .unwrap();
1025 tokio::fs::write(dir.path().join("other.rs"), "needle\n")
1026 .await
1027 .unwrap();
1028 tokio::fs::write(dir.path().join("small.txt"), "needle\n")
1029 .await
1030 .unwrap();
1031 let mut oversized = vec![b'x'; MAX_FILE_BYTES as usize + 1];
1032 oversized[..6].copy_from_slice(b"needle");
1033 tokio::fs::write(dir.path().join("large.rs"), oversized)
1034 .await
1035 .unwrap();
1036
1037 let result = run(
1038 &GrepTool::new(),
1039 json!({
1040 "pattern": "needle",
1041 "path": dir.path(),
1042 "include_ignored": true,
1043 "type": "rust",
1044 "glob": "**/{small,large}.rs"
1045 }),
1046 )
1047 .await
1048 .unwrap();
1049 let lines = result_lines(&result);
1050 assert_eq!(lines.len(), 1);
1051 assert!(lines[0].contains("small.rs"));
1052 }
1053}