opendev_tools_impl/file_search/
grep_tool.rs1use 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#[derive(Debug)]
16pub struct GrepTool;
17
18impl GrepTool {
19 pub(super) fn build_rg_command(args: &GrepArgs, search_path: &Path) -> Command {
21 let mut cmd = Command::new("rg");
22
23 cmd.arg("--no-heading");
25 cmd.arg("--color=never");
26
27 match args.output_mode {
29 OutputMode::FilesWithMatches => {
30 cmd.arg("-l");
31 }
32 OutputMode::Count => {
33 cmd.arg("-c");
34 }
35 OutputMode::Content => {
36 if args.line_numbers {
38 cmd.arg("-n");
39 }
40 }
41 }
42
43 if args.case_insensitive {
45 cmd.arg("-i");
46 }
47
48 if args.multiline {
50 cmd.arg("-U");
51 cmd.arg("--multiline-dotall");
52 }
53
54 if args.fixed_string {
56 cmd.arg("-F");
57 }
58
59 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 if let Some(ref glob) = args.glob {
72 cmd.arg("--glob");
73 cmd.arg(glob);
74 }
75
76 if let Some(ref file_type) = args.file_type {
78 cmd.arg("--type");
79 cmd.arg(file_type);
80 }
81
82 if let Some(ignore_file) = default_ignore_file() {
86 cmd.arg("--ignore-file");
87 cmd.arg(ignore_file);
88 }
89
90 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 !grep_args.fixed_string && regex::Regex::new(&grep_args.pattern).is_err() {
217 grep_args.fixed_string = true;
218 }
219
220 if !grep_args.fixed_string && grep_args.pattern.contains("\\n") {
223 grep_args.multiline = true;
224 }
225
226 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}