llm_coding_tools_serdesai/absolute/
grep.rs

1//! Grep content search tool using [`AbsolutePathResolver`].
2
3use async_trait::async_trait;
4use llm_coding_tools_core::ToolContext;
5use llm_coding_tools_core::operations::{DEFAULT_MAX_LINE_LENGTH, grep_search};
6use llm_coding_tools_core::path::AbsolutePathResolver;
7use llm_coding_tools_core::tool_names;
8use serde::Deserialize;
9use serdes_ai::tools::{
10    RunContext, SchemaBuilder, Tool, ToolDefinition, ToolError, ToolResult, ToolReturn,
11};
12
13use crate::convert::to_serdes_result;
14
15const DEFAULT_LIMIT: usize = 100;
16const MAX_LIMIT: usize = 2000;
17
18/// Internal args for JSON deserialization.
19#[derive(Debug, Deserialize)]
20struct GrepArgs {
21    /// Regular expression pattern to search for in file contents.
22    pattern: String,
23    /// Absolute directory path to search in.
24    path: String,
25    /// File pattern to filter search results (e.g., "*.rs", "*.{ts,tsx}").
26    #[serde(default)]
27    include: Option<String>,
28    /// Maximum number of matches to return (default: 100, max: 2000).
29    #[serde(default)]
30    limit: Option<usize>,
31}
32
33/// Tool for searching file contents using regex patterns.
34///
35/// The `LINE_NUMBERS` const generic controls output format:
36/// - `true` (default): Lines prefixed with `L{number}: `
37/// - `false`: Raw matching lines
38#[derive(Debug, Clone, Default)]
39pub struct GrepTool<const LINE_NUMBERS: bool = true>;
40
41impl<const LINE_NUMBERS: bool> GrepTool<LINE_NUMBERS> {
42    /// Creates a new grep tool instance.
43    #[inline]
44    pub fn new() -> Self {
45        Self
46    }
47}
48
49#[async_trait]
50impl<Deps: Send + Sync, const LINE_NUMBERS: bool> Tool<Deps> for GrepTool<LINE_NUMBERS> {
51    fn definition(&self) -> ToolDefinition {
52        // Description matches rig exactly
53        let description = if LINE_NUMBERS {
54            "Search file contents using regex patterns. Returns matches with file paths, line numbers, and content, sorted by file modification time."
55        } else {
56            "Search file contents using regex patterns. Returns matches with file paths and content, sorted by file modification time."
57        };
58        let schema = SchemaBuilder::new()
59            .string(
60                "pattern",
61                "Regular expression pattern to search for in file contents",
62                true,
63            )
64            .string("path", "Absolute directory path to search in", true)
65            .string(
66                "include",
67                "File pattern to filter search results (e.g., \"*.rs\", \"*.{ts,tsx}\")",
68                false,
69            )
70            .integer_constrained(
71                "limit",
72                "Maximum number of matches to return (default: 100, max: 2000)",
73                false,
74                Some(1),
75                Some(2000),
76            )
77            .build()
78            .expect("schema build should not fail");
79
80        ToolDefinition::new(tool_names::GREP, description).with_parameters(schema)
81    }
82
83    async fn call(&self, _ctx: &RunContext<Deps>, args: serde_json::Value) -> ToolResult {
84        let args: GrepArgs = serde_json::from_value(args)
85            .map_err(|e| ToolError::validation_error(tool_names::GREP, None, e.to_string()))?;
86
87        let pattern = args.pattern.trim();
88        if pattern.is_empty() {
89            return Err(ToolError::validation_error(
90                tool_names::GREP,
91                Some("pattern".to_string()),
92                "pattern must not be empty".to_string(),
93            ));
94        }
95
96        let limit = args.limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT);
97        if limit == 0 {
98            return Err(ToolError::validation_error(
99                tool_names::GREP,
100                Some("limit".to_string()),
101                "limit must be greater than zero".to_string(),
102            ));
103        }
104
105        let include = args.include.as_deref().and_then(|s| {
106            let trimmed = s.trim();
107            if trimmed.is_empty() {
108                None
109            } else {
110                Some(trimmed)
111            }
112        });
113
114        let resolver = AbsolutePathResolver;
115        let result = grep_search(&resolver, pattern, include, &args.path, limit);
116
117        match result {
118            Err(e) => to_serdes_result(tool_names::GREP, Err(e)),
119            Ok(grep_output) => {
120                if grep_output.files.is_empty() {
121                    return Ok(ToolReturn::text("No matches found."));
122                }
123
124                let output = grep_output.format::<LINE_NUMBERS>(limit, DEFAULT_MAX_LINE_LENGTH);
125                Ok(ToolReturn::text(output))
126            }
127        }
128    }
129}
130
131impl<const LINE_NUMBERS: bool> ToolContext for GrepTool<LINE_NUMBERS> {
132    const NAME: &'static str = tool_names::GREP;
133
134    fn context(&self) -> &'static str {
135        llm_coding_tools_core::context::GREP_ABSOLUTE
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142    use serde_json::json;
143    use serdes_ai::tools::RunContext;
144    use tempfile::TempDir;
145
146    fn mock_ctx() -> RunContext<()> {
147        RunContext::new((), "test-model")
148    }
149
150    #[tokio::test]
151    async fn finds_content_with_required_path() {
152        let dir = TempDir::new().unwrap();
153        std::fs::write(dir.path().join("test.txt"), "hello world\nfoo bar").unwrap();
154
155        let tool: GrepTool<true> = GrepTool::new();
156        let result = tool
157            .call(
158                &mock_ctx(),
159                json!({
160                    "pattern": "hello",
161                    "path": dir.path().to_string_lossy()
162                }),
163            )
164            .await
165            .unwrap();
166
167        let text = result.as_text().unwrap();
168        assert!(text.contains("Found 1 matches"));
169        assert!(text.contains("L1: hello world"));
170    }
171
172    #[tokio::test]
173    async fn validates_limit() {
174        let dir = TempDir::new().unwrap();
175        std::fs::write(dir.path().join("test.txt"), "hello").unwrap();
176
177        let tool: GrepTool<true> = GrepTool::new();
178        let result = tool
179            .call(
180                &mock_ctx(),
181                json!({
182                    "pattern": "hello",
183                    "path": dir.path().to_string_lossy(),
184                    "limit": 0
185                }),
186            )
187            .await;
188
189        let err = result.unwrap_err();
190        assert!(matches!(err, ToolError::ValidationFailed { .. }));
191        // Check the error contains the validation message
192        match err {
193            ToolError::ValidationFailed { errors, .. } => {
194                assert!(!errors.is_empty());
195                assert!(errors[0].message.contains("limit"));
196            }
197            _ => panic!("Expected ValidationFailed"),
198        }
199    }
200
201    #[tokio::test]
202    async fn returns_no_matches_message_when_empty() {
203        let dir = TempDir::new().unwrap();
204        std::fs::write(dir.path().join("test.txt"), "hello world").unwrap();
205
206        let tool: GrepTool<true> = GrepTool::new();
207        let result = tool
208            .call(
209                &mock_ctx(),
210                json!({
211                    "pattern": "nonexistent_pattern_xyz",
212                    "path": dir.path().to_string_lossy()
213                }),
214            )
215            .await
216            .unwrap();
217
218        let text = result.as_text().unwrap();
219        assert_eq!(text, "No matches found.");
220    }
221
222    #[tokio::test]
223    async fn include_filter_restricts_to_matching_files() {
224        let dir = TempDir::new().unwrap();
225        std::fs::write(dir.path().join("code.rs"), "fn hello() {}").unwrap();
226        std::fs::write(dir.path().join("code.py"), "def hello(): pass").unwrap();
227        std::fs::write(dir.path().join("readme.txt"), "hello world").unwrap();
228
229        let tool: GrepTool<true> = GrepTool::new();
230
231        // Search only .rs files
232        let result = tool
233            .call(
234                &mock_ctx(),
235                json!({
236                    "pattern": "hello",
237                    "path": dir.path().to_string_lossy(),
238                    "include": "*.rs"
239                }),
240            )
241            .await
242            .unwrap();
243
244        let text = result.as_text().unwrap();
245        assert!(text.contains("Found 1 matches"));
246        assert!(text.contains("code.rs"));
247        assert!(!text.contains("code.py"));
248        assert!(!text.contains("readme.txt"));
249    }
250
251    #[tokio::test]
252    async fn truncates_long_lines_at_max_length() {
253        let dir = TempDir::new().unwrap();
254        // Create a line longer than MAX_LINE_LENGTH (2000 chars)
255        let long_line = format!("prefix_{}_suffix", "x".repeat(2500));
256        std::fs::write(dir.path().join("long.txt"), &long_line).unwrap();
257
258        let tool: GrepTool<true> = GrepTool::new();
259        let result = tool
260            .call(
261                &mock_ctx(),
262                json!({
263                    "pattern": "prefix",
264                    "path": dir.path().to_string_lossy()
265                }),
266            )
267            .await
268            .unwrap();
269
270        let text = result.as_text().unwrap();
271        assert!(text.contains("Found 1 matches"));
272        // The line should be truncated - it should contain prefix but not suffix
273        assert!(text.contains("prefix_"));
274        assert!(!text.contains("_suffix"));
275        // Verify the match line doesn't exceed DEFAULT_MAX_LINE_LENGTH
276        for line in text.lines() {
277            if line.contains("prefix_") {
278                // Line format is "  L1: content", so actual content is line.len() - prefix
279                let content_start = line.find("prefix_").unwrap();
280                let content = &line[content_start..];
281                assert!(content.len() <= DEFAULT_MAX_LINE_LENGTH);
282            }
283        }
284    }
285}