llm_coding_tools_rig/absolute/
grep.rs

1//! Grep content search tool using [`AbsolutePathResolver`].
2
3use llm_coding_tools_core::operations::{grep_search, DEFAULT_MAX_LINE_LENGTH};
4use llm_coding_tools_core::path::AbsolutePathResolver;
5use llm_coding_tools_core::tool_names;
6use llm_coding_tools_core::{ToolContext, ToolError, ToolOutput};
7use rig::completion::ToolDefinition;
8use rig::tool::Tool;
9use schemars::{schema_for, JsonSchema};
10use serde::Deserialize;
11
12const DEFAULT_LIMIT: usize = 100;
13const MAX_LIMIT: usize = 2000;
14
15fn default_limit() -> Option<usize> {
16    Some(DEFAULT_LIMIT)
17}
18
19/// Arguments for the grep tool.
20#[derive(Debug, Deserialize, JsonSchema)]
21pub struct GrepArgs {
22    /// Regex pattern to search for in file contents.
23    pub pattern: String,
24    /// Absolute directory path to search in.
25    pub path: String,
26    /// Optional file glob filter (e.g., "*.rs", "*.{ts,tsx}").
27    #[serde(default)]
28    pub include: Option<String>,
29    /// Maximum number of matches to return (default: 100, max: 2000).
30    #[serde(default = "default_limit")]
31    pub limit: Option<usize>,
32}
33
34/// Tool for searching file contents using regex patterns.
35#[derive(Debug, Clone, Default)]
36pub struct GrepTool<const LINE_NUMBERS: bool = true>;
37
38impl<const LINE_NUMBERS: bool> GrepTool<LINE_NUMBERS> {
39    /// Creates a new grep tool instance.
40    #[inline]
41    pub fn new() -> Self {
42        Self
43    }
44}
45
46impl<const LINE_NUMBERS: bool> Tool for GrepTool<LINE_NUMBERS> {
47    const NAME: &'static str = tool_names::GREP;
48
49    type Error = ToolError;
50    type Args = GrepArgs;
51    type Output = ToolOutput;
52
53    async fn definition(&self, _prompt: String) -> ToolDefinition {
54        let description = if LINE_NUMBERS {
55            "Search file contents using regex patterns. Returns matches with file paths, \
56                line numbers, and content, sorted by file modification time."
57        } else {
58            "Search file contents using regex patterns. Returns matches with file paths \
59                and content, sorted by file modification time."
60        };
61        ToolDefinition {
62            name: <Self as Tool>::NAME.to_string(),
63            description: description.to_string(),
64            parameters: serde_json::to_value(schema_for!(GrepArgs))
65                .expect("schema serialization should not fail"),
66        }
67    }
68
69    async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
70        let pattern = args.pattern.trim();
71        if pattern.is_empty() {
72            return Err(ToolError::InvalidPattern(
73                "pattern must not be empty".into(),
74            ));
75        }
76
77        let limit = args.limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT);
78        if limit == 0 {
79            return Err(ToolError::Validation(
80                "limit must be greater than zero".into(),
81            ));
82        }
83
84        let include = args.include.as_deref().and_then(|s| {
85            let trimmed = s.trim();
86            if trimmed.is_empty() {
87                None
88            } else {
89                Some(trimmed)
90            }
91        });
92
93        let resolver = AbsolutePathResolver;
94        let result = grep_search(&resolver, pattern, include, &args.path, limit)?;
95
96        if result.files.is_empty() {
97            return Ok(ToolOutput::new("No matches found."));
98        }
99
100        let output = result.format::<LINE_NUMBERS>(limit, DEFAULT_MAX_LINE_LENGTH);
101
102        Ok(if result.truncated {
103            ToolOutput::truncated(output)
104        } else {
105            ToolOutput::new(output)
106        })
107    }
108}
109
110impl<const LINE_NUMBERS: bool> ToolContext for GrepTool<LINE_NUMBERS> {
111    const NAME: &'static str = tool_names::GREP;
112
113    fn context(&self) -> &'static str {
114        llm_coding_tools_core::context::GREP_ABSOLUTE
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use tempfile::TempDir;
122
123    #[tokio::test]
124    async fn finds_matching_content() {
125        let dir = TempDir::new().unwrap();
126        std::fs::write(dir.path().join("test.txt"), "hello world").unwrap();
127        let tool: GrepTool<true> = GrepTool::new();
128        let result = tool
129            .call(GrepArgs {
130                pattern: "hello".to_string(),
131                path: dir.path().to_string_lossy().to_string(),
132                include: None,
133                limit: None,
134            })
135            .await
136            .unwrap();
137        assert!(result.content.contains("Found 1 matches"));
138        assert!(result.content.contains("L1: hello world"));
139    }
140
141    #[tokio::test]
142    async fn rejects_relative_path() {
143        let tool: GrepTool = GrepTool::new();
144        let result = tool
145            .call(GrepArgs {
146                pattern: "test".to_string(),
147                path: "relative/path".to_string(),
148                include: None,
149                limit: None,
150            })
151            .await;
152        assert!(matches!(result, Err(ToolError::InvalidPath(_))));
153    }
154
155    #[tokio::test]
156    async fn rejects_empty_pattern() {
157        let tool: GrepTool = GrepTool::new();
158        let result = tool
159            .call(GrepArgs {
160                pattern: "   ".to_string(),
161                path: "/tmp".to_string(),
162                include: None,
163                limit: None,
164            })
165            .await;
166        assert!(matches!(result, Err(ToolError::InvalidPattern(_))));
167    }
168
169    #[tokio::test]
170    async fn truncates_long_lines_at_utf8_boundary() {
171        let dir = TempDir::new().unwrap();
172
173        // Create a line that's > MAX_LINE_LENGTH (2000) bytes with multibyte chars at the boundary.
174        // Use 1998 ASCII chars + "日本語" (9 bytes for 3 chars) = 2007 bytes total.
175        // Truncating at byte 2000 would land inside the multibyte sequence without floor_char_boundary.
176        let long_line = format!("match_me {}{}", "a".repeat(1989), "日本語");
177        assert!(
178            long_line.len() > 2000,
179            "test setup: line must exceed MAX_LINE_LENGTH"
180        );
181
182        std::fs::write(dir.path().join("utf8_test.txt"), &long_line).unwrap();
183
184        let tool: GrepTool<true> = GrepTool::new();
185        let result = tool
186            .call(GrepArgs {
187                pattern: "match_me".to_string(),
188                path: dir.path().to_string_lossy().to_string(),
189                include: None,
190                limit: None,
191            })
192            .await
193            .unwrap();
194
195        // Should not panic and output should be valid UTF-8
196        assert!(result.content.contains("Found 1 matches"));
197        assert!(result.content.contains("L1:"));
198        // The output should be valid UTF-8 (this is implicitly tested by using .contains on a String)
199    }
200}