Skip to main content

robit_agent/tool/
grep.rs

1//! `grep` tool — searches file contents for a pattern.
2
3use async_trait::async_trait;
4use regex::Regex;
5use serde::Deserialize;
6use serde_json::Value;
7use std::path::Path;
8
9use super::{resolve_path, Tool, ToolContext, ToolResult};
10use crate::error::Result;
11
12pub struct GrepTool {
13    /// Max output lines before truncation.
14    max_output_lines: usize,
15    /// Max output bytes before truncation.
16    max_output_bytes: usize,
17}
18
19#[derive(Debug, Deserialize)]
20struct GrepArgs {
21    pattern: String,
22    file_path: Option<String>,
23    dir_path: Option<String>,
24    #[serde(default)]
25    ignore_case: bool,
26    #[serde(default)]
27    recursive: bool,
28}
29
30impl GrepTool {
31    pub fn new(max_output_lines: usize, max_output_bytes: usize) -> Self {
32        Self {
33            max_output_lines,
34            max_output_bytes,
35        }
36    }
37}
38
39#[async_trait]
40impl Tool for GrepTool {
41    fn name(&self) -> &str {
42        "grep"
43    }
44
45    fn description(&self) -> &str {
46        "Search for a text pattern in files or directories. Supports regex."
47    }
48
49    fn parameters_schema(&self) -> Value {
50        serde_json::json!({
51            "type": "object",
52            "properties": {
53                "pattern": {
54                    "type": "string",
55                    "description": "Search pattern (regex)"
56                },
57                "file_path": {
58                    "type": "string",
59                    "description": "Single file path (use either this or dir_path)"
60                },
61                "dir_path": {
62                    "type": "string",
63                    "description": "Directory path (use either this or file_path)"
64                },
65                "ignore_case": {
66                    "type": "boolean",
67                    "description": "Case-insensitive search, default false"
68                },
69                "recursive": {
70                    "type": "boolean",
71                    "description": "Recursively search subdirectories (only effective with dir_path), default false"
72                }
73            },
74            "required": ["pattern"]
75        })
76    }
77
78    fn requires_confirmation(&self) -> bool {
79        false
80    }
81
82    async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<ToolResult> {
83        let parsed: GrepArgs = match serde_json::from_value(args) {
84            Ok(a) => a,
85            Err(e) => return Ok(ToolResult::error(format!("Argument parsing failed: {}", e))),
86        };
87
88        // Validate arguments
89        if parsed.file_path.is_none() && parsed.dir_path.is_none() {
90            return Ok(ToolResult::error("Either file_path or dir_path must be specified".to_string()));
91        }
92        if parsed.file_path.is_some() && parsed.dir_path.is_some() {
93            return Ok(ToolResult::error("Cannot specify both file_path and dir_path simultaneously".to_string()));
94        }
95
96        // Build regex
97        let regex_pattern = if parsed.ignore_case {
98            format!("(?i){}", parsed.pattern)
99        } else {
100            parsed.pattern.clone()
101        };
102
103        let regex = match Regex::new(&regex_pattern) {
104            Ok(r) => r,
105            Err(e) => {
106                return Ok(ToolResult::error(format!("Invalid regex '{}': {}", parsed.pattern, e)));
107            }
108        };
109
110        // Collect files to search
111        let mut files_to_search = Vec::new();
112
113        if let Some(fp) = &parsed.file_path {
114            let path = resolve_path(fp, &ctx.working_dir);
115            if !path.exists() {
116                return Ok(ToolResult::error(format!("File not found: {}", path.display())));
117            }
118            if path.is_dir() {
119                return Ok(ToolResult::error(format!("'{}' is a directory, use dir_path parameter instead", path.display())));
120            }
121            files_to_search.push(path);
122        } else if let Some(dp) = &parsed.dir_path {
123            let path = resolve_path(dp, &ctx.working_dir);
124            if !path.exists() {
125                return Ok(ToolResult::error(format!("Directory not found: {}", path.display())));
126            }
127            if !path.is_dir() {
128                return Ok(ToolResult::error(format!("'{}' is not a directory", path.display())));
129            }
130            collect_files(&path, parsed.recursive, &mut files_to_search).await;
131        }
132
133        // Search each file
134        let mut output = String::new();
135        output.push_str(&format!("Search: {}\n", parsed.pattern));
136        output.push_str(&format!("{}", "─".repeat(50)));
137
138        let mut line_count = 0;
139        let mut byte_count = output.len();
140        let mut has_matches = false;
141
142        for file_path in &files_to_search {
143            // Read file
144            let content = match tokio::fs::read_to_string(file_path).await {
145                Ok(c) => c,
146                Err(_) => continue,
147            };
148
149            // Search lines
150            let mut file_matches = Vec::new();
151            for (line_num, line) in content.lines().enumerate() {
152                if regex.is_match(line) {
153                    file_matches.push((line_num + 1, line));
154                }
155            }
156
157            if !file_matches.is_empty() {
158                has_matches = true;
159
160                let rel_path = match file_path.strip_prefix(&ctx.working_dir) {
161                    Ok(p) => p.to_path_buf(),
162                    Err(_) => file_path.clone(),
163                };
164
165                let file_header = format!("\nšŸ“„ {}\n", rel_path.display());
166                if byte_count + file_header.len() > self.max_output_bytes {
167                    output.push_str(&format!(
168                        "\n... (Output truncated, byte limit of {} bytes reached)",
169                        self.max_output_bytes
170                    ));
171                    break;
172                }
173                output.push_str(&file_header);
174                byte_count += file_header.len();
175
176                for (line_num, line) in file_matches {
177                    if line_count >= self.max_output_lines {
178                        output.push_str(&format!(
179                            "\n... (Output truncated, line limit of {} lines reached)",
180                            self.max_output_lines
181                        ));
182                        return Ok(ToolResult::success(output));
183                    }
184
185                    let line_str = format!("{:>6}: {}\n", line_num, line);
186                    if byte_count + line_str.len() > self.max_output_bytes {
187                        output.push_str(&format!(
188                            "\n... (Output truncated, byte limit of {} bytes reached)",
189                            self.max_output_bytes
190                        ));
191                        break;
192                    }
193                    output.push_str(&line_str);
194                    byte_count += line_str.len();
195                    line_count += 1;
196                }
197            }
198        }
199
200        if !has_matches {
201            output.push_str("\n(No matches found)");
202        }
203
204        Ok(ToolResult::success(output))
205    }
206}
207
208async fn collect_files(dir: &Path, recursive: bool, files: &mut Vec<std::path::PathBuf>) {
209    let mut dirs = vec![dir.to_path_buf()];
210    while let Some(dir) = dirs.pop() {
211        let mut entries = match tokio::fs::read_dir(&dir).await {
212            Ok(e) => e,
213            Err(_) => continue,
214        };
215        loop {
216            let entry = match entries.next_entry().await {
217                Ok(Some(e)) => e,
218                Ok(None) => break,
219                Err(_) => continue,
220            };
221            let path = entry.path();
222            if path.is_file() {
223                files.push(path);
224            } else if path.is_dir() && recursive {
225                dirs.push(path);
226            }
227        }
228    }
229}