Skip to main content

rpi_tools/tools/
grep.rs

1//! Mirrors `packages/coding-agent/src/core/tools/grep.ts` — the built-in `grep`
2//! `AgentTool`. Searches file contents for a regex/literal pattern and returns
3//! matching lines with `file:line: text` (and optional `-line-` context lines).
4//! Read-only — goes through the `FileSystem` trait so it runs against both
5//! `OsExecutionEnv` and `InMemoryExecutionEnv`.
6//!
7//! Divergence from TS (documented): the TS tool **shells out to `rg`**
8//! (ripgrep), parsing its `--json` stream, and re-reads files only for context
9//! blocks. The Rust port implements search **in-process** via the `FileSystem`
10//! trait + the `regex` crate, so the tool works against any `ExecutionEnv`
11//! (including `InMemoryExecutionEnv`, which has no shell). Trade-offs: no
12//! `.gitignore` awareness in v1 (only `.git/` directories are skipped; revisit
13//! via the `ignore` crate for an `OsExecutionEnv`-only fast path), no
14//! auto-download of a binary, and traversal order is a deterministic sorted BFS
15//! rather than rg's walk order. Output shape, match/context line formats,
16//! per-line + byte truncation, and the match-limit semantics match TS exactly.
17
18use std::collections::VecDeque;
19use std::path::Path;
20use std::sync::Arc;
21
22use async_trait::async_trait;
23use rpi_agent::agent_tool::AgentTool;
24use rpi_agent::error::AgentError;
25use rpi_agent::types::{AgentToolResult, TextContentOrImage, ToolResultPartial};
26use rpi_ai::types::Tool;
27use schemars::JsonSchema;
28use serde::Deserialize;
29use tokio_util::sync::CancellationToken;
30
31use crate::env::ExecutionEnv;
32use crate::env::FileKind;
33use crate::path_utils::resolve_read_tool_path;
34use crate::tools::tool_context::ExecutionToolContext;
35use crate::truncate::{
36    format_size, truncate_head, truncate_line_default, TruncationOptions, TruncationResult,
37    DEFAULT_MAX_BYTES, GREP_MAX_LINE_LENGTH,
38};
39
40/// Default max matches. Mirrors TS `grep.ts::DEFAULT_LIMIT`.
41const DEFAULT_LIMIT: u32 = 100;
42
43/// Input for the grep tool. Mirrors TS `GrepToolInput`. `camelCase` field names
44/// match the TS wire schema (so `ignoreCase`/`ignore_case` deserialization lines
45/// up with what the model emits).
46#[derive(Debug, Clone, JsonSchema, Deserialize)]
47#[serde(rename_all = "camelCase")]
48pub struct GrepInput {
49    /// Search pattern (regex or literal string).
50    pub pattern: String,
51    /// Directory or file to search (default: current directory).
52    #[serde(default)]
53    pub path: Option<String>,
54    /// Filter files by glob pattern, e.g. `*.ts` or `**/*.spec.ts`.
55    #[serde(default)]
56    pub glob: Option<String>,
57    /// Case-insensitive search (default false).
58    #[serde(default)]
59    pub ignore_case: Option<bool>,
60    /// Treat pattern as a literal string instead of regex (default false).
61    #[serde(default)]
62    pub literal: Option<bool>,
63    /// Number of lines to show before and after each match (default 0).
64    #[serde(default)]
65    pub context: Option<u32>,
66    /// Maximum number of matches to return (default 100).
67    #[serde(default)]
68    pub limit: Option<u32>,
69}
70
71/// Structured details for the grep tool. Mirrors TS `GrepToolDetails`.
72#[derive(Debug, Clone, serde::Serialize)]
73pub struct GrepToolDetails {
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub truncation: Option<TruncationResult>,
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub match_limit_reached: Option<u32>,
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub lines_truncated: Option<bool>,
80}
81
82/// Options for `create_grep_tool`. The TS tool carries an optional `operations`
83/// bag for remote backends; the Rust port always uses the `FileSystem` trait, so
84/// this is an empty forward-compat seam (parity with `ReadToolOptions`).
85#[derive(Debug, Clone, Default)]
86pub struct GrepToolOptions {}
87
88/// The built-in `grep` tool. Holds an `Arc<dyn ExecutionEnv>` (read-only view).
89pub struct GrepTool {
90    schema: Tool,
91    env: Arc<dyn ExecutionEnv>,
92}
93
94impl GrepTool {
95    fn schema() -> Tool {
96        let params = schemars::schema_for!(GrepInput);
97        Tool {
98            name: "grep".to_string(),
99            description: format!(
100                "Search file contents for a pattern. Returns matching lines with file paths and \
101                 line numbers. Skips `.git/` directories. Output is truncated to {DEFAULT_LIMIT} \
102                 matches or {kb}KB (whichever is hit first). Long lines are truncated to \
103                 {GREP_MAX_LINE_LENGTH} chars.",
104                kb = DEFAULT_MAX_BYTES / 1024
105            ),
106            parameters: rpi_ai::types::Schema::new(
107                serde_json::to_value(params).unwrap_or_default(),
108            ),
109            constrained_sampling: None,
110        }
111    }
112}
113
114/// Build the `grep` tool from a context. Mirrors TS `createGrepTool`.
115pub fn create_grep_tool(
116    context: &ExecutionToolContext,
117    options: Option<GrepToolOptions>,
118) -> Arc<dyn AgentTool> {
119    let _ = options; // forward-compat seam, no surface in v1
120    Arc::new(GrepTool {
121        schema: GrepTool::schema(),
122        env: context.env().clone(),
123    })
124}
125
126#[async_trait]
127impl AgentTool for GrepTool {
128    fn schema(&self) -> &Tool {
129        &self.schema
130    }
131    fn label(&self) -> &str {
132        "grep"
133    }
134
135    async fn execute(
136        &self,
137        _tool_call_id: &str,
138        params: serde_json::Value,
139        signal: CancellationToken,
140        _on_update: Arc<dyn Fn(ToolResultPartial) + Send + Sync>,
141    ) -> Result<AgentToolResult, AgentError> {
142        let input: GrepInput = serde_json::from_value(params)
143            .map_err(|e| AgentError::Validation(format!("grep input invalid: {e}")))?;
144
145        let cancel = Some(&signal);
146        let effective_limit = input.limit.unwrap_or(DEFAULT_LIMIT).max(1) as usize;
147        let context_val = input.context.unwrap_or(0).min(u32::MAX) as usize;
148
149        // Compile the regex. `literal` ⇒ escape; `ignore_case` ⇒ case-insensitive.
150        let pattern = if input.literal.unwrap_or(false) {
151            regex::escape(&input.pattern)
152        } else {
153            input.pattern.clone()
154        };
155        let re = regex::RegexBuilder::new(&pattern)
156            .case_insensitive(input.ignore_case.unwrap_or(false))
157            .build()
158            .map_err(|e| AgentError::Validation(format!("grep pattern invalid: {e}")))?;
159
160        // Compile the optional glob filter. `has_separator` decides whether to
161        // match the full relative path (`**/*.ts`) or just the basename (`*.ts`),
162        // mirroring rg's basename-glob leniency.
163        let glob: Option<(globset::GlobMatcher, bool)> = match input.glob.as_deref() {
164            Some(g) if !g.is_empty() => {
165                let compiled = globset::Glob::new(g)
166                    .map_err(|e| AgentError::Validation(format!("grep glob invalid: {e}")))?
167                    .compile_matcher();
168                Some((compiled, g.contains('/')))
169            }
170            _ => None,
171        };
172
173        // Resolve the search path. A `.`/absent path resolves to the env cwd.
174        let search_path =
175            resolve_read_tool_path(&*self.env, input.path.as_deref().unwrap_or("."), cancel)
176                .await
177                .map_err(file_err_to_agent)?;
178
179        // Determine dir vs file (following a symlinked root via canonical_path).
180        let info = self
181            .env
182            .file_info(&search_path, cancel)
183            .await
184            .map_err(file_err_to_agent)?;
185        let root_kind = if info.kind == FileKind::Symlink {
186            match self.env.canonical_path(&search_path, cancel).await {
187                Ok(canon) => match self.env.file_info(&canon.to_string_lossy(), cancel).await {
188                    Ok(i) => i.kind,
189                    Err(_) => info.kind, // treat as whatever lstat said
190                },
191                Err(_) => info.kind,
192            }
193        } else {
194            info.kind
195        };
196
197        // Mutable collection state shared across the file-search helper.
198        let mut matches: usize = 0;
199        let mut match_limit_reached = false;
200        let mut lines_truncated = false;
201        let mut output_lines: Vec<String> = Vec::new();
202
203        if root_kind == FileKind::Directory {
204            // Iterative BFS walk (avoids async-recursion boxing). Sorted entries
205            // for deterministic output order.
206            let mut queue: VecDeque<(String, String)> = VecDeque::new();
207            queue.push_back((search_path.clone(), String::new()));
208            while let Some((abs_dir, rel_dir)) = queue.pop_front() {
209                if matches >= effective_limit {
210                    break;
211                }
212                let mut entries = match self.env.list_dir(&abs_dir, cancel).await {
213                    Ok(e) => e,
214                    Err(_) => continue, // unreadable dir — skip (TS relies on rg to ignore)
215                };
216                entries.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
217                for entry in entries {
218                    if matches >= effective_limit {
219                        match_limit_reached = true;
220                        break;
221                    }
222                    let child_abs = entry.path.to_string_lossy().into_owned();
223                    match entry.kind {
224                        FileKind::Directory => {
225                            // Skip `.git/` (v1 has no full .gitignore support).
226                            if entry.name == ".git" {
227                                continue;
228                            }
229                            let rel = if rel_dir.is_empty() {
230                                entry.name.clone()
231                            } else {
232                                format!("{rel_dir}/{}", entry.name)
233                            };
234                            queue.push_back((child_abs, rel));
235                        }
236                        FileKind::File => {
237                            let rel = if rel_dir.is_empty() {
238                                entry.name.clone()
239                            } else {
240                                format!("{rel_dir}/{}", entry.name)
241                            };
242                            search_file(
243                                &*self.env,
244                                cancel,
245                                &re,
246                                glob.as_ref(),
247                                context_val,
248                                effective_limit,
249                                &child_abs,
250                                &rel,
251                                &mut matches,
252                                &mut match_limit_reached,
253                                &mut lines_truncated,
254                                &mut output_lines,
255                            )
256                            .await?;
257                        }
258                        FileKind::Symlink => {
259                            // Skip symlinks during the walk (avoids loops). The
260                            // search root symlink is followed above.
261                        }
262                    }
263                }
264            }
265        } else if root_kind == FileKind::File {
266            let rel = basename(&search_path);
267            search_file(
268                &*self.env,
269                cancel,
270                &re,
271                glob.as_ref(),
272                context_val,
273                effective_limit,
274                &search_path,
275                &rel,
276                &mut matches,
277                &mut match_limit_reached,
278                &mut lines_truncated,
279                &mut output_lines,
280            )
281            .await?;
282        } else {
283            // Symlink root that didn't resolve to a file/dir (or not_supported).
284            return Err(AgentError::Tool(format!(
285                "Path is neither a file nor a directory: {search_path}"
286            )));
287        }
288
289        if output_lines.is_empty() {
290            return Ok(AgentToolResult {
291                content: vec![TextContentOrImage::text("No matches found")],
292                details: serde_json::Value::Null,
293                ..Default::default()
294            });
295        }
296
297        let raw_output = output_lines.join("\n");
298        // Byte cap only — line cap disabled (match count already capped).
299        let truncation = truncate_head(
300            &raw_output,
301            TruncationOptions {
302                max_lines: Some(usize::MAX),
303                max_bytes: None,
304            },
305        );
306        let was_truncated = truncation.truncated;
307        let mut output = truncation.content.clone();
308        let mut details = GrepToolDetails {
309            truncation: None,
310            match_limit_reached: None,
311            lines_truncated: None,
312        };
313        let mut notices: Vec<String> = Vec::new();
314        if match_limit_reached {
315            notices.push(format!(
316                "{effective_limit} matches limit reached. Use limit={} for more, or refine pattern",
317                effective_limit * 2
318            ));
319            details.match_limit_reached = Some(effective_limit as u32);
320        }
321        if was_truncated {
322            notices.push(format!("{} limit reached", format_size(DEFAULT_MAX_BYTES)));
323            details.truncation = Some(truncation);
324        }
325        if lines_truncated {
326            notices.push(format!(
327                "Some lines truncated to {GREP_MAX_LINE_LENGTH} chars. Use read tool to see full lines"
328            ));
329            details.lines_truncated = Some(true);
330        }
331        if !notices.is_empty() {
332            output.push_str(&format!("\n\n[{}]", notices.join(". ")));
333        }
334
335        let details_value = serde_json::to_value(&details).unwrap_or(serde_json::Value::Null);
336        Ok(AgentToolResult {
337            content: vec![TextContentOrImage::text(output)],
338            details: details_value,
339            ..Default::default()
340        })
341    }
342}
343
344/// Search a single file for `re`, appending formatted match/context lines to
345/// `output_lines` and bumping `matches`. Reads via the `FileSystem` trait; an
346/// unreadable file (non-UTF-8, etc.) is silently skipped (a clean divergence —
347/// in-process search can't match lines it can't decode).
348#[allow(clippy::too_many_arguments)]
349async fn search_file(
350    env: &dyn ExecutionEnv,
351    cancel: Option<&CancellationToken>,
352    re: &regex::Regex,
353    glob: Option<&(globset::GlobMatcher, bool)>,
354    context_val: usize,
355    effective_limit: usize,
356    abs: &str,
357    rel: &str,
358    matches: &mut usize,
359    match_limit_reached: &mut bool,
360    lines_truncated: &mut bool,
361    output_lines: &mut Vec<String>,
362) -> Result<(), AgentError> {
363    // Glob filter (basename vs full-relative-path per `has_separator`).
364    if let Some((matcher, has_sep)) = glob {
365        let basename = basename(abs);
366        let passes = if *has_sep {
367            matcher.is_match(rel)
368        } else {
369            matcher.is_match(&basename)
370        };
371        if !passes {
372            return Ok(());
373        }
374    }
375
376    // Read; skip on failure (non-UTF-8 / unreadable).
377    let text = match env.read_text_file(abs, cancel).await {
378        Ok(t) => t,
379        Err(_) => return Ok(()),
380    };
381    // Normalize line endings, split on `\n` (mirror TS getFileLines).
382    let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
383    let lines: Vec<&str> = normalized.split('\n').collect();
384
385    for (idx, line) in lines.iter().enumerate() {
386        if *matches >= effective_limit {
387            *match_limit_reached = true;
388            return Ok(());
389        }
390        if !re.is_match(line) {
391            continue;
392        }
393        let line_number = idx + 1; // 1-indexed.
394        *matches += 1;
395
396        if context_val == 0 {
397            // No context: emit the single match line (truncated).
398            let out = truncate_line_default(line);
399            if out.was_truncated {
400                *lines_truncated = true;
401            }
402            output_lines.push(format!("{rel}:{line_number}: {}", out.text));
403        } else {
404            // Context block: match line uses `:`, context lines use `-`.
405            let start = line_number.saturating_sub(context_val).max(1);
406            let end = (line_number + context_val).min(lines.len());
407            for current in start..=end {
408                let text_line = lines.get(current - 1).copied().unwrap_or("");
409                let out = truncate_line_default(text_line);
410                if out.was_truncated {
411                    *lines_truncated = true;
412                }
413                if current == line_number {
414                    output_lines.push(format!("{rel}:{current}: {}", out.text));
415                } else {
416                    output_lines.push(format!("{rel}-{current}- {}", out.text));
417                }
418            }
419        }
420    }
421    Ok(())
422}
423
424/// Filename component of a path string (POSIX-agnostic: splits on `/` and `\`).
425fn basename(p: &str) -> String {
426    Path::new(p)
427        .file_name()
428        .map(|n| n.to_string_lossy().into_owned())
429        .unwrap_or_else(|| p.to_string())
430}
431
432/// Map a `FileError` into an `AgentError::Tool` (same shape as `read.rs`).
433fn file_err_to_agent(e: crate::error::FileError) -> AgentError {
434    AgentError::Tool(e.to_string())
435}