Skip to main content

bamboo_tools/tools/
grep.rs

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    /// Regex pattern
35    pattern: String,
36    /// File or directory to search. An explicit file bypasses ignore rules. Narrow this for expensive or multiline searches.
37    #[serde(default)]
38    #[schemars(with = "String", skip_serializing_if = "Option::is_none")]
39    path: Option<String>,
40    /// Glob file filter used to limit candidate files
41    #[serde(default)]
42    #[schemars(with = "String", skip_serializing_if = "Option::is_none")]
43    glob: Option<String>,
44    /// Output mode. Prefer files_with_matches for broad discovery, then refine with Read or content mode.
45    #[serde(default)]
46    #[schemars(with = "OutputMode", skip_serializing_if = "Option::is_none")]
47    output_mode: Option<OutputMode>,
48    /// Lines before match
49    #[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    /// Lines after match
56    #[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    /// Lines before and after match
63    #[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    /// Show line numbers
70    #[serde(rename = "-n", default)]
71    #[schemars(with = "bool", skip_serializing_if = "Option::is_none")]
72    line_numbers: Option<bool>,
73    /// Case insensitive
74    #[serde(rename = "-i", default)]
75    #[schemars(with = "bool", skip_serializing_if = "Option::is_none")]
76    case_insensitive: Option<bool>,
77    /// File type filter (for example rust, js, ts, py)
78    #[serde(default)]
79    #[schemars(with = "String", skip_serializing_if = "Option::is_none")]
80    r#type: Option<String>,
81    /// Limit output entries. Keep this small for broad queries.
82    #[serde(default)]
83    #[schemars(
84        schema_with = "parameter_schema::number",
85        skip_serializing_if = "Option::is_none"
86    )]
87    head_limit: Option<usize>,
88    /// Enable multiline regex. Requires a narrowed path.
89    #[serde(default)]
90    #[schemars(with = "bool", skip_serializing_if = "Option::is_none")]
91    multiline: Option<bool>,
92    /// Include gitignored files. Requires an explicit path; scan/result limits and fixed directory exclusions still apply.
93    #[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        // Path inspection and ignore::Walk perform synchronous directory IO.
367        // Keep discovery off the runtime so a slow filesystem cannot delay
368        // lease renewal or other tasks on an Actix current-thread worker.
369        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                    &regex,
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            // A watchdog makes a regression fail instead of deadlocking the
520            // current-thread test runtime; this is not a traversal benchmark.
521            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}