llm_coding_tools_rig/absolute/
grep.rs1use 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#[derive(Debug, Deserialize, JsonSchema)]
21pub struct GrepArgs {
22 pub pattern: String,
24 pub path: String,
26 #[serde(default)]
28 pub include: Option<String>,
29 #[serde(default = "default_limit")]
31 pub limit: Option<usize>,
32}
33
34#[derive(Debug, Clone, Default)]
36pub struct GrepTool<const LINE_NUMBERS: bool = true>;
37
38impl<const LINE_NUMBERS: bool> GrepTool<LINE_NUMBERS> {
39 #[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 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 assert!(result.content.contains("Found 1 matches"));
197 assert!(result.content.contains("L1:"));
198 }
200}