llm_coding_tools_serdesai/absolute/
grep.rs1use 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#[derive(Debug, Deserialize)]
20struct GrepArgs {
21 pattern: String,
23 path: String,
25 #[serde(default)]
27 include: Option<String>,
28 #[serde(default)]
30 limit: Option<usize>,
31}
32
33#[derive(Debug, Clone, Default)]
39pub struct GrepTool<const LINE_NUMBERS: bool = true>;
40
41impl<const LINE_NUMBERS: bool> GrepTool<LINE_NUMBERS> {
42 #[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 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 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 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 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 assert!(text.contains("prefix_"));
274 assert!(!text.contains("_suffix"));
275 for line in text.lines() {
277 if line.contains("prefix_") {
278 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}