Skip to main content

oxi_agent/tools/
grep.rs

1use super::path_security::PathGuard;
2/// Grep tool - search files for patterns
3use super::{AgentTool, AgentToolResult, ToolContext, ToolError};
4use crate::tools::typed::TypedTool;
5use async_trait::async_trait;
6use regex::RegexBuilder;
7use schemars::JsonSchema;
8use serde::Deserialize;
9use serde_json::{Value, json};
10use std::path::{Path, PathBuf};
11use tokio::fs;
12use tokio::sync::oneshot;
13
14/// Maximum characters per line in grep output
15const GREP_MAX_LINE_LENGTH: usize = 500;
16
17/// Truncate a single line to max characters, adding "... [truncated]" suffix.
18fn truncate_line(line: &str) -> (String, bool) {
19    if line.len() <= GREP_MAX_LINE_LENGTH {
20        (line.to_string(), false)
21    } else {
22        (
23            format!("{}... [truncated]", &line[..GREP_MAX_LINE_LENGTH]),
24            true,
25        )
26    }
27}
28
29/// Typed arguments for [`GrepTool`].
30///
31/// Deserialized from the LLM's JSON tool call parameters.
32#[derive(Deserialize, JsonSchema)]
33pub struct GrepArgs {
34    pattern: String,
35    #[serde(default)]
36    path: String,
37    #[serde(default)]
38    case_insensitive: bool,
39    #[serde(default)]
40    literal: bool,
41    #[serde(default)]
42    context: usize,
43    include: Option<String>,
44    #[serde(default = "default_grep_results")]
45    max_results: usize,
46}
47
48fn default_grep_results() -> usize {
49    100
50}
51
52/// GrepTool.
53pub struct GrepTool {
54    root_dir: Option<PathBuf>,
55}
56
57impl GrepTool {
58    /// Create with no explicit root (uses ToolContext.workspace_dir at runtime).
59    pub fn new() -> Self {
60        Self { root_dir: None }
61    }
62
63    /// Create with a specific working directory (overrides ToolContext).
64    pub fn with_cwd(cwd: PathBuf) -> Self {
65        Self {
66            root_dir: Some(cwd),
67        }
68    }
69
70    /// Check if a filename matches a simple glob pattern like "*.rs", "*.ts"
71    fn matches_glob(file_name: &str, pattern: &str) -> bool {
72        if let Some(ext) = pattern.strip_prefix("*.") {
73            file_name.ends_with(ext)
74        } else if pattern.contains('*') {
75            // Simple wildcard matching
76            let parts: Vec<&str> = pattern.split('*').collect();
77            if parts.len() == 2 {
78                file_name.starts_with(parts[0]) && file_name.ends_with(parts[1])
79            } else {
80                file_name == pattern
81            }
82        } else {
83            file_name == pattern
84        }
85    }
86
87    #[allow(clippy::type_complexity)]
88    #[allow(clippy::too_many_arguments)]
89    async fn grep_impl(
90        root_dir: &Path,
91        pattern: &str,
92        path: &str,
93        case_insensitive: bool,
94        literal: bool,
95        context_before: usize,
96        context_after: usize,
97        include: Option<&str>,
98        max_results: usize,
99    ) -> Result<(String, bool), ToolError> {
100        // Security: validate path with PathGuard
101        let guard = PathGuard::new(root_dir);
102        let root = guard
103            .validate_traversal(Path::new(path))
104            .map_err(|e| e.to_string())?;
105
106        if !root.exists() {
107            return Err(format!("Path not found: {}", path));
108        }
109
110        // Escape the pattern for literal matching if needed
111        let pattern = if literal {
112            regex::escape(pattern)
113        } else {
114            pattern.to_string()
115        };
116
117        let re = RegexBuilder::new(&pattern)
118            .case_insensitive(case_insensitive)
119            .build()
120            .map_err(|e| format!("Invalid pattern '{}': {}", pattern, e))?;
121
122        let mut matches: Vec<String> = Vec::new();
123        let mut lines_truncated = false;
124        Self::grep_walk(
125            &root,
126            &root,
127            &re,
128            include,
129            context_before,
130            context_after,
131            max_results,
132            &mut matches,
133            &mut lines_truncated,
134        )
135        .await?;
136
137        if matches.is_empty() {
138            Ok(("No matches found".to_string(), false))
139        } else {
140            let header = format!("Found {} matches:\n", matches.len());
141            Ok((header + &matches.join("\n"), lines_truncated))
142        }
143    }
144
145    /// Read a file and return lines as a vector
146    async fn read_file_lines(path: &Path) -> Result<Vec<String>, ToolError> {
147        match fs::read_to_string(path).await {
148            Ok(content) => {
149                // Normalize line endings: replace CRLF and standalone CR with LF, then split
150                let normalized = content.replace("\r\n", "\n").replace('\r', "\n");
151                Ok(normalized.lines().map(|s| s.to_string()).collect())
152            }
153            Err(e) => Err(format!("Cannot read file: {}", e)),
154        }
155    }
156
157    #[allow(clippy::too_many_arguments)]
158    async fn grep_walk(
159        root: &Path,
160        current: &Path,
161        re: &regex::Regex,
162        include: Option<&str>,
163        context_before: usize,
164        context_after: usize,
165        max_results: usize,
166        matches: &mut Vec<String>,
167        lines_truncated: &mut bool,
168    ) -> Result<(), ToolError> {
169        if matches.len() >= max_results {
170            return Ok(());
171        }
172
173        // Detect and skip broken symlinks - they cause read_dir to fail
174        // and should not cause the entire search to fail
175        if current
176            .symlink_metadata()
177            .map(|m| m.file_type().is_symlink())
178            .unwrap_or(false)
179            && !current.exists()
180        {
181            return Ok(());
182        }
183
184        if current.is_file() {
185            // Check include filter
186            if let Some(glob) = include {
187                let file_name = current
188                    .file_name()
189                    .map(|n| n.to_string_lossy().to_string())
190                    .unwrap_or_default();
191                if !Self::matches_glob(&file_name, glob) {
192                    return Ok(());
193                }
194            }
195
196            // Try to read and search the file
197            match Self::read_file_lines(current).await {
198                Ok(lines) => {
199                    let relative = current.strip_prefix(root).unwrap_or(current).display();
200
201                    for (i, line) in lines.iter().enumerate() {
202                        if re.is_match(line) {
203                            // Check if adding this match would exceed max_results
204                            // We may need to add context lines too
205                            let context_lines_count = if context_before > 0 || context_after > 0 {
206                                let start = if context_before > 0 {
207                                    i.saturating_sub(context_before)
208                                } else {
209                                    i
210                                };
211                                let end = std::cmp::min(lines.len(), i + context_after + 1);
212                                end - start
213                            } else {
214                                1
215                            };
216
217                            if matches.len() + context_lines_count > max_results {
218                                // Can't add this match with its context, stop
219                                return Ok(());
220                            }
221
222                            // Add context lines before match
223                            if context_before > 0 && i > 0 {
224                                let start = i.saturating_sub(context_before);
225                                for (j, context_line) in
226                                    lines.iter().enumerate().take(i).skip(start)
227                                {
228                                    let (truncated_text, was_truncated) =
229                                        truncate_line(context_line);
230                                    if was_truncated {
231                                        *lines_truncated = true;
232                                    }
233                                    matches.push(format!(
234                                        "{}-{}- {}",
235                                        relative,
236                                        j + 1,
237                                        truncated_text
238                                    ));
239                                }
240                            }
241
242                            // Add the match line
243                            let (truncated_text, was_truncated) = truncate_line(line);
244                            if was_truncated {
245                                *lines_truncated = true;
246                            }
247                            matches.push(format!("{}:{}: {}", relative, i + 1, truncated_text));
248
249                            // Add context lines after match
250                            if context_after > 0 {
251                                let end = std::cmp::min(lines.len(), i + context_after + 1);
252                                for (j, context_line) in
253                                    lines.iter().enumerate().take(end).skip(i + 1)
254                                {
255                                    let (truncated_text, was_truncated) =
256                                        truncate_line(context_line);
257                                    if was_truncated {
258                                        *lines_truncated = true;
259                                    }
260                                    matches.push(format!(
261                                        "{}-{}- {}",
262                                        relative,
263                                        j + 1,
264                                        truncated_text
265                                    ));
266                                }
267                            }
268
269                            if matches.len() >= max_results {
270                                return Ok(());
271                            }
272                        }
273                    }
274                }
275                Err(_) => {
276                    // Skip files we can't read (binary, permissions, etc.)
277                }
278            }
279            return Ok(());
280        }
281
282        // Directory: walk entries
283        let mut entries = fs::read_dir(current)
284            .await
285            .map_err(|e| format!("Cannot read directory {}: {}", current.display(), e))?;
286
287        while let Some(entry) = entries
288            .next_entry()
289            .await
290            .map_err(|e| format!("Error reading entry: {}", e))?
291        {
292            let entry_path = entry.path();
293
294            // Skip hidden files/dirs
295            if entry_path
296                .file_name()
297                .map(|n| n.to_string_lossy().starts_with('.'))
298                .unwrap_or(false)
299            {
300                continue;
301            }
302
303            // Skip common non-searchable dirs
304            if entry_path.is_dir() {
305                let dir_name = entry_path
306                    .file_name()
307                    .map(|n| n.to_string_lossy().to_string())
308                    .unwrap_or_default();
309                if matches!(
310                    dir_name.as_str(),
311                    "node_modules"
312                        | "target"
313                        | ".git"
314                        | "dist"
315                        | "build"
316                        | "__pycache__"
317                        | ".venv"
318                        | "venv"
319                ) {
320                    continue;
321                }
322            }
323
324            Box::pin(Self::grep_walk(
325                root,
326                &entry_path,
327                re,
328                include,
329                context_before,
330                context_after,
331                max_results,
332                matches,
333                lines_truncated,
334            ))
335            .await?;
336        }
337
338        Ok(())
339    }
340}
341
342impl Default for GrepTool {
343    fn default() -> Self {
344        Self::new()
345    }
346}
347
348#[async_trait]
349impl AgentTool for GrepTool {
350    fn name(&self) -> &str {
351        "grep"
352    }
353
354    fn label(&self) -> &str {
355        "Grep"
356    }
357
358    fn essential(&self) -> bool {
359        true
360    }
361    fn description(&self) -> &str {
362        "Search files for a pattern. Returns matching lines with file paths and line numbers. Use literal=true to treat pattern as a literal string. Use context=n to show n lines before and after matches. Long lines are truncated to 500 chars."
363    }
364
365    fn parameters_schema(&self) -> Value {
366        json!({
367            "type": "object",
368            "properties": {
369                "pattern": {
370                    "type": "string",
371                    "description": "The pattern to search for (regex by default, or literal string if literal=true)"
372                },
373                "path": {
374                    "type": "string",
375                    "description": "The directory or file to search in",
376                    "default": "."
377                },
378                "case_insensitive": {
379                    "type": "boolean",
380                    "description": "If true, perform case-insensitive search",
381                    "default": false
382                },
383                "literal": {
384                    "type": "boolean",
385                    "description": "If true, treat pattern as a literal string instead of regex",
386                    "default": false
387                },
388                "context": {
389                    "type": "integer",
390                    "description": "Number of lines to show before and after each match",
391                    "default": 0
392                },
393                "include": {
394                    "type": "string",
395                    "description": "Glob pattern to filter files (e.g., '*.rs', '*.ts')"
396                },
397                "max_results": {
398                    "type": "integer",
399                    "description": "Maximum number of results to return",
400                    "default": 100
401                }
402            },
403            "required": ["pattern"]
404        })
405    }
406
407    async fn execute(
408        &self,
409        _tool_call_id: &str,
410        params: Value,
411        _signal: Option<oneshot::Receiver<()>>,
412        ctx: &ToolContext,
413    ) -> Result<AgentToolResult, ToolError> {
414        let args: GrepArgs =
415            serde_json::from_value(params).map_err(|e| format!("invalid params: {e}"))?;
416        self.execute_typed(_tool_call_id, args, _signal, ctx).await
417    }
418}
419
420#[async_trait]
421impl TypedTool for GrepTool {
422    type Args = GrepArgs;
423    async fn execute_typed(
424        &self,
425        _tool_call_id: &str,
426        args: Self::Args,
427        _signal: Option<oneshot::Receiver<()>>,
428        ctx: &ToolContext,
429    ) -> Result<AgentToolResult, ToolError> {
430        let path = if args.path.is_empty() {
431            "."
432        } else {
433            &args.path
434        };
435        let pattern = &args.pattern;
436        let case_insensitive = args.case_insensitive;
437        let literal = args.literal;
438        let context = args.context;
439        let include = args.include.as_deref();
440        let max_results = args.max_results;
441
442        // ── Internal URL dispatch ──
443        if let Some(ref resolver) = ctx.url_resolver
444            && resolver.can_resolve(path)
445        {
446            let resolved = resolver.resolve(path).await?;
447            let pattern_str = if literal {
448                regex::escape(pattern)
449            } else {
450                pattern.to_string()
451            };
452            let re = RegexBuilder::new(&pattern_str)
453                .case_insensitive(case_insensitive)
454                .build()
455                .map_err(|e| format!("Invalid pattern '{}': {}", pattern, e))?;
456
457            let lines: Vec<&str> = resolved.content.lines().collect();
458            let mut matches: Vec<String> = Vec::new();
459            let mut lines_truncated = false;
460
461            for (i, line) in lines.iter().enumerate() {
462                if matches.len() >= max_results {
463                    break;
464                }
465                if re.is_match(line) {
466                    if context > 0 && i > 0 {
467                        let start = i.saturating_sub(context);
468                        for (j, ctx_line) in lines.iter().enumerate().take(i).skip(start) {
469                            let (truncated, was_trunc) = truncate_line(ctx_line);
470                            if was_trunc {
471                                lines_truncated = true;
472                            }
473                            matches.push(format!("{}-{}- {}", path, j + 1, truncated));
474                        }
475                    }
476                    let (truncated, was_trunc) = truncate_line(line);
477                    if was_trunc {
478                        lines_truncated = true;
479                    }
480                    matches.push(format!("{}:{}: {}", path, i + 1, truncated));
481                    if context > 0 {
482                        let end = std::cmp::min(lines.len(), i + context + 1);
483                        for (j, ctx_line) in lines.iter().enumerate().take(end).skip(i + 1) {
484                            let (truncated, was_trunc) = truncate_line(ctx_line);
485                            if was_trunc {
486                                lines_truncated = true;
487                            }
488                            matches.push(format!("{}-{}- {}", path, j + 1, truncated));
489                        }
490                    }
491                    if matches.len() >= max_results {
492                        break;
493                    }
494                }
495            }
496            let output = if matches.is_empty() {
497                "No matches found".to_string()
498            } else {
499                format!("Found {} matches:\n{}", matches.len(), matches.join("\n"))
500            };
501            let mut result = AgentToolResult::success(output);
502            if lines_truncated {
503                result.metadata = Some(json!({
504                    "lines_truncated": true,
505                    "message": "Some lines truncated to 500 chars. Use read tool to see full lines."
506                }));
507            }
508            return Ok(result);
509        }
510
511        let root = self.root_dir.as_deref().unwrap_or(ctx.root());
512        match Self::grep_impl(
513            root,
514            pattern,
515            path,
516            case_insensitive,
517            literal,
518            context,
519            context,
520            include,
521            max_results,
522        )
523        .await
524        {
525            Ok((output, lines_truncated)) => {
526                let mut result = AgentToolResult::success(output);
527                if lines_truncated {
528                    result.metadata = Some(json!({
529                        "lines_truncated": true,
530                        "message": "Some lines truncated to 500 chars. Use read tool to see full lines."
531                    }));
532                }
533                Ok(result)
534            }
535            Err(e) => Ok(AgentToolResult::error(e)),
536        }
537    }
538}