Skip to main content

opendev_tools_impl/file_search/
grep_tool.rs

1//! GrepTool — search file contents using ripgrep.
2
3use std::collections::HashMap;
4use std::path::Path;
5
6use opendev_tools_core::{BaseTool, ToolContext, ToolResult};
7use tokio::process::Command;
8
9use super::excludes::default_ignore_file;
10use super::types::{GrepArgs, OutputMode, RgError};
11use crate::dir_hints::list_available_dirs;
12use crate::path_utils::{resolve_dir_path, validate_path_access};
13
14/// Tool for searching file contents using ripgrep.
15#[derive(Debug)]
16pub struct GrepTool;
17
18impl GrepTool {
19    /// Build the `rg` command from the parsed arguments.
20    pub(super) fn build_rg_command(args: &GrepArgs, search_path: &Path) -> Command {
21        let mut cmd = Command::new("rg");
22
23        // Always use these flags for machine-parseable output
24        cmd.arg("--no-heading");
25        cmd.arg("--color=never");
26
27        // Output mode
28        match args.output_mode {
29            OutputMode::FilesWithMatches => {
30                cmd.arg("-l");
31            }
32            OutputMode::Count => {
33                cmd.arg("-c");
34            }
35            OutputMode::Content => {
36                // Line numbers on by default for content mode
37                if args.line_numbers {
38                    cmd.arg("-n");
39                }
40            }
41        }
42
43        // Case insensitivity
44        if args.case_insensitive {
45            cmd.arg("-i");
46        }
47
48        // Multiline
49        if args.multiline {
50            cmd.arg("-U");
51            cmd.arg("--multiline-dotall");
52        }
53
54        // Fixed string (literal, no regex)
55        if args.fixed_string {
56            cmd.arg("-F");
57        }
58
59        // Context lines
60        if let Some(c) = args.context {
61            cmd.arg(format!("--context={c}"));
62        }
63        if let Some(a) = args.after_context {
64            cmd.arg(format!("-A={a}"));
65        }
66        if let Some(b) = args.before_context {
67            cmd.arg(format!("-B={b}"));
68        }
69
70        // Glob filter
71        if let Some(ref glob) = args.glob {
72            cmd.arg("--glob");
73            cmd.arg(glob);
74        }
75
76        // File type filter
77        if let Some(ref file_type) = args.file_type {
78            cmd.arg("--type");
79            cmd.arg(file_type);
80        }
81
82        // Default exclusions via ignore file (safety net — rg already respects .gitignore).
83        // Uses --ignore-file because rg's --glob override set treats negation-only
84        // patterns as "exclude everything", while ignore files work correctly.
85        if let Some(ignore_file) = default_ignore_file() {
86            cmd.arg("--ignore-file");
87            cmd.arg(ignore_file);
88        }
89
90        // Pattern and path
91        cmd.arg(&args.pattern);
92        cmd.arg(search_path);
93
94        cmd
95    }
96}
97
98#[async_trait::async_trait]
99impl BaseTool for GrepTool {
100    fn name(&self) -> &str {
101        "grep"
102    }
103
104    fn description(&self) -> &str {
105        "Search file contents using regex patterns via ripgrep. \
106         Results in files_with_matches mode are sorted by modification time (newest first). \
107         Use fixed_string=true for literal (non-regex) matching."
108    }
109
110    fn parameter_schema(&self) -> serde_json::Value {
111        serde_json::json!({
112            "type": "object",
113            "properties": {
114                "pattern": {
115                    "type": "string",
116                    "description": "Regex pattern to search for (supports full regex syntax)"
117                },
118                "path": {
119                    "type": "string",
120                    "description": "File or directory to search in (defaults to working directory)"
121                },
122                "glob": {
123                    "type": "string",
124                    "description": "Glob pattern to filter files (e.g., \"*.rs\", \"*.{ts,tsx}\") — maps to rg --glob"
125                },
126                "include": {
127                    "type": "string",
128                    "description": "Alias for glob — file pattern to include in the search (e.g., \"*.js\", \"*.{ts,tsx}\")"
129                },
130                "type": {
131                    "type": "string",
132                    "description": "File type to search (e.g., \"py\", \"rs\", \"js\") — maps to rg --type"
133                },
134                "-i": {
135                    "type": "boolean",
136                    "description": "Case insensitive search"
137                },
138                "multiline": {
139                    "type": "boolean",
140                    "description": "Enable multiline mode where . matches newlines and patterns can span lines"
141                },
142                "fixed_string": {
143                    "type": "boolean",
144                    "description": "Treat pattern as a literal string, not a regex"
145                },
146                "output_mode": {
147                    "type": "string",
148                    "enum": ["content", "files_with_matches", "count"],
149                    "description": "Output mode: 'content' shows matching lines, 'files_with_matches' shows file paths, 'count' shows match counts"
150                },
151                "context": {
152                    "type": "number",
153                    "description": "Number of lines to show before and after each match (rg -C)"
154                },
155                "-A": {
156                    "type": "number",
157                    "description": "Number of lines to show after each match"
158                },
159                "-B": {
160                    "type": "number",
161                    "description": "Number of lines to show before each match"
162                },
163                "-C": {
164                    "type": "number",
165                    "description": "Alias for context"
166                },
167                "-n": {
168                    "type": "boolean",
169                    "description": "Show line numbers in output (default true for content mode)"
170                },
171                "head_limit": {
172                    "type": "number",
173                    "description": "Limit output to first N lines/entries"
174                },
175                "offset": {
176                    "type": "number",
177                    "description": "Skip first N lines/entries before applying head_limit"
178                }
179            },
180            "required": ["pattern"]
181        })
182    }
183
184    async fn execute(
185        &self,
186        args: HashMap<String, serde_json::Value>,
187        ctx: &ToolContext,
188    ) -> ToolResult {
189        let mut grep_args = match GrepArgs::from_map(&args) {
190            Ok(a) => a,
191            Err(e) => return ToolResult::fail(e),
192        };
193
194        let search_path = grep_args
195            .path
196            .as_deref()
197            .map(|p| resolve_dir_path(p, &ctx.working_dir))
198            .unwrap_or_else(|| ctx.working_dir.clone());
199
200        if let Err(msg) = validate_path_access(&search_path, &ctx.working_dir) {
201            return ToolResult::fail(msg);
202        }
203
204        if !search_path.exists() {
205            let available = list_available_dirs(&ctx.working_dir);
206            return ToolResult::fail(format!(
207                "Path not found: {}\n\nAvailable directories in working dir ({}):\n{}",
208                search_path.display(),
209                ctx.working_dir.display(),
210                available
211            ));
212        }
213
214        // If pattern is not valid regex and fixed_string wasn't explicitly set,
215        // auto-enable fixed_string mode so literal patterns like "}},{"  just work.
216        if !grep_args.fixed_string && regex::Regex::new(&grep_args.pattern).is_err() {
217            grep_args.fixed_string = true;
218        }
219
220        // If pattern contains literal \n (newline escape), auto-enable multiline
221        // so ripgrep accepts it instead of erroring.
222        if !grep_args.fixed_string && grep_args.pattern.contains("\\n") {
223            grep_args.multiline = true;
224        }
225
226        // Try ripgrep first, fall back to built-in grep
227        match self.run_rg(&grep_args, &search_path).await {
228            Ok(result) => result,
229            Err(RgError::NotInstalled) => {
230                tracing::warn!("ripgrep (rg) not found, falling back to built-in search");
231                self.fallback_search(&grep_args, &search_path)
232            }
233            Err(RgError::Timeout) => ToolResult::fail(
234                "Search timed out after 30 seconds. Try a more specific pattern or path.",
235            ),
236            Err(RgError::Other(e)) => ToolResult::fail(format!("Search failed: {e}")),
237        }
238    }
239}