1use super::{Tool, ToolResult};
4use anyhow::Result;
5use async_trait::async_trait;
6use serde_json::{Value, json};
7use std::path::PathBuf;
8use std::time::Instant;
9use tokio::fs;
10
11use crate::telemetry::{FileChange, TOOL_EXECUTIONS, ToolExecution, record_persistent};
12
13pub struct ReadTool;
15
16impl Default for ReadTool {
17 fn default() -> Self {
18 Self::new()
19 }
20}
21
22impl ReadTool {
23 pub fn new() -> Self {
24 Self
25 }
26}
27
28#[async_trait]
29impl Tool for ReadTool {
30 fn id(&self) -> &str {
31 "read"
32 }
33
34 fn name(&self) -> &str {
35 "Read File"
36 }
37
38 fn description(&self) -> &str {
39 "read(path: string, offset?: int, limit?: int) - Read the contents of a file. Provide the file path to read."
40 }
41
42 fn parameters(&self) -> Value {
43 json!({
44 "type": "object",
45 "properties": {
46 "path": {
47 "type": "string",
48 "description": "The path to the file to read"
49 },
50 "offset": {
51 "type": "integer",
52 "description": "Line number to start reading from (1-indexed)"
53 },
54 "limit": {
55 "type": "integer",
56 "description": "Maximum number of lines to read"
57 }
58 },
59 "required": ["path"],
60 "example": {
61 "path": "src/main.rs",
62 "offset": 1,
63 "limit": 100
64 }
65 })
66 }
67
68 async fn execute(&self, args: Value) -> Result<ToolResult> {
69 let start = Instant::now();
70
71 let path = match args["path"].as_str() {
72 Some(p) => p,
73 None => {
74 return Ok(ToolResult::structured_error(
75 "INVALID_ARGUMENT",
76 "read",
77 "path is required",
78 Some(vec!["path"]),
79 Some(json!({"path": "src/main.rs"})),
80 ));
81 }
82 };
83 let offset = args["offset"].as_u64().map(|n| n as usize);
84 let limit = args["limit"].as_u64().map(|n| n as usize);
85
86 let content = fs::read_to_string(path).await?;
87
88 let lines: Vec<&str> = content.lines().collect();
89 let start_line = offset
90 .map(|o| o.saturating_sub(1))
91 .unwrap_or(0)
92 .min(lines.len());
93 let end_line = limit
94 .map(|l| (start_line + l).min(lines.len()))
95 .unwrap_or(lines.len());
96
97 let selected: String = lines[start_line..end_line]
98 .iter()
99 .enumerate()
100 .map(|(i, line)| format!("{:4} | {}", start_line + i + 1, line))
101 .collect::<Vec<_>>()
102 .join("\n");
103
104 let duration = start.elapsed();
105
106 let file_change = FileChange::read(path, Some((start_line as u32 + 1, end_line as u32)));
108
109 let mut exec = ToolExecution::start(
110 "read",
111 json!({
112 "path": path,
113 "offset": offset,
114 "limit": limit,
115 }),
116 );
117 exec.add_file_change(file_change);
118 let exec = exec.complete_success(
119 format!("Read {} lines from {}", end_line - start_line, path),
120 duration,
121 );
122 TOOL_EXECUTIONS.record(exec.success);
123 let _ = record_persistent(
124 "tool_execution",
125 &serde_json::to_value(&exec).unwrap_or_default(),
126 );
127
128 Ok(ToolResult::success(selected)
129 .with_metadata("total_lines", json!(lines.len()))
130 .with_metadata("read_lines", json!(end_line - start_line)))
131 }
132}
133
134pub struct WriteTool;
136
137impl Default for WriteTool {
138 fn default() -> Self {
139 Self::new()
140 }
141}
142
143impl WriteTool {
144 pub fn new() -> Self {
145 Self
146 }
147}
148
149#[async_trait]
150impl Tool for WriteTool {
151 fn id(&self) -> &str {
152 "write"
153 }
154
155 fn name(&self) -> &str {
156 "Write File"
157 }
158
159 fn description(&self) -> &str {
160 "write(path: string, content: string) - Write content to a file. Creates the file if it doesn't exist, or overwrites it."
161 }
162
163 fn parameters(&self) -> Value {
164 json!({
165 "type": "object",
166 "properties": {
167 "path": {
168 "type": "string",
169 "description": "The path to the file to write"
170 },
171 "content": {
172 "type": "string",
173 "description": "The content to write to the file"
174 }
175 },
176 "required": ["path", "content"],
177 "example": {
178 "path": "src/config.rs",
179 "content": "// Configuration module\n\npub struct Config {\n pub debug: bool,\n}\n"
180 }
181 })
182 }
183
184 async fn execute(&self, args: Value) -> Result<ToolResult> {
185 let start = Instant::now();
186
187 let path = match args["path"].as_str() {
188 Some(p) => p,
189 None => {
190 return Ok(ToolResult::structured_error(
191 "INVALID_ARGUMENT",
192 "write",
193 "path is required",
194 Some(vec!["path"]),
195 Some(json!({"path": "src/example.rs", "content": "// file content"})),
196 ));
197 }
198 };
199 let content = match args["content"].as_str() {
200 Some(c) => c,
201 None => {
202 return Ok(ToolResult::structured_error(
203 "INVALID_ARGUMENT",
204 "write",
205 "content is required",
206 Some(vec!["content"]),
207 Some(json!({"path": path, "content": "// file content"})),
208 ));
209 }
210 };
211
212 if let Some(parent) = PathBuf::from(path).parent() {
214 fs::create_dir_all(parent).await?;
215 }
216
217 let existed = fs::metadata(path).await.is_ok();
219 let old_content = if existed {
220 fs::read_to_string(path).await.ok()
221 } else {
222 None
223 };
224
225 fs::write(path, content).await?;
226
227 let duration = start.elapsed();
228
229 let file_change = if existed {
231 FileChange::modify(
232 path,
233 old_content.as_deref().unwrap_or(""),
234 content,
235 Some((1, content.lines().count() as u32)),
236 )
237 } else {
238 FileChange::create(path, content)
239 };
240
241 let mut exec = ToolExecution::start(
242 "write",
243 json!({
244 "path": path,
245 "content_length": content.len(),
246 }),
247 );
248 exec.add_file_change(file_change);
249 let exec = exec.complete_success(
250 format!("Wrote {} bytes to {}", content.len(), path),
251 duration,
252 );
253 TOOL_EXECUTIONS.record(exec.success);
254 let _ = record_persistent(
255 "tool_execution",
256 &serde_json::to_value(&exec).unwrap_or_default(),
257 );
258
259 Ok(ToolResult::success(format!(
260 "Wrote {} bytes to {}",
261 content.len(),
262 path
263 )))
264 }
265}
266
267pub struct ListTool;
269
270impl Default for ListTool {
271 fn default() -> Self {
272 Self::new()
273 }
274}
275
276impl ListTool {
277 pub fn new() -> Self {
278 Self
279 }
280}
281
282#[async_trait]
283impl Tool for ListTool {
284 fn id(&self) -> &str {
285 "list"
286 }
287
288 fn name(&self) -> &str {
289 "List Directory"
290 }
291
292 fn description(&self) -> &str {
293 "list(path: string) - List the contents of a directory."
294 }
295
296 fn parameters(&self) -> Value {
297 json!({
298 "type": "object",
299 "properties": {
300 "path": {
301 "type": "string",
302 "description": "The path to the directory to list"
303 }
304 },
305 "required": ["path"],
306 "example": {
307 "path": "src/"
308 }
309 })
310 }
311
312 async fn execute(&self, args: Value) -> Result<ToolResult> {
313 let path = match args["path"].as_str() {
314 Some(p) => p,
315 None => {
316 return Ok(ToolResult::structured_error(
317 "INVALID_ARGUMENT",
318 "list",
319 "path is required",
320 Some(vec!["path"]),
321 Some(json!({"path": "src/"})),
322 ));
323 }
324 };
325
326 let mut entries = fs::read_dir(path).await?;
327 let mut items = Vec::new();
328
329 while let Some(entry) = entries.next_entry().await? {
330 let name = entry.file_name().to_string_lossy().to_string();
331 let file_type = entry.file_type().await?;
332
333 let suffix = if file_type.is_dir() {
334 "/"
335 } else if file_type.is_symlink() {
336 "@"
337 } else {
338 ""
339 };
340
341 items.push(format!("{}{}", name, suffix));
342 }
343
344 items.sort();
345 Ok(ToolResult::success(items.join("\n")).with_metadata("count", json!(items.len())))
346 }
347}
348
349pub struct GlobTool {
351 root: PathBuf,
352}
353
354impl Default for GlobTool {
355 fn default() -> Self {
356 Self::new()
357 }
358}
359
360impl GlobTool {
361 pub fn new() -> Self {
362 Self {
363 root: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
364 }
365 }
366
367 pub fn with_root(root: PathBuf) -> Self {
368 Self { root }
369 }
370}
371
372#[async_trait]
373impl Tool for GlobTool {
374 fn id(&self) -> &str {
375 "glob"
376 }
377
378 fn name(&self) -> &str {
379 "Glob Search"
380 }
381
382 fn description(&self) -> &str {
383 "glob(pattern: string, limit?: int) - Find files matching a glob pattern (e.g., **/*.rs, src/**/*.ts)"
384 }
385
386 fn parameters(&self) -> Value {
387 json!({
388 "type": "object",
389 "properties": {
390 "pattern": {
391 "type": "string",
392 "description": "The glob pattern to match files"
393 },
394 "limit": {
395 "type": "integer",
396 "description": "Maximum number of results to return"
397 }
398 },
399 "required": ["pattern"],
400 "example": {
401 "pattern": "src/**/*.rs",
402 "limit": 50
403 }
404 })
405 }
406
407 async fn execute(&self, args: Value) -> Result<ToolResult> {
408 let pattern = match args["pattern"].as_str() {
409 Some(p) => p,
410 None => {
411 return Ok(ToolResult::structured_error(
412 "INVALID_ARGUMENT",
413 "glob",
414 "pattern is required",
415 Some(vec!["pattern"]),
416 Some(json!({"pattern": "src/**/*.rs"})),
417 ));
418 }
419 };
420 let limit = args["limit"].as_u64().unwrap_or(100) as usize;
421
422 let mut matches = Vec::new();
423
424 let pattern_path = {
427 let candidate = PathBuf::from(pattern);
428 if candidate.is_absolute() {
429 candidate
430 } else {
431 self.root.join(candidate)
432 }
433 };
434
435 let (base_dir, match_pattern) = {
436 let p = pattern_path.as_path();
437 let mut prefix = std::path::PathBuf::new();
438 let mut found_meta = false;
439 for component in p.components() {
440 let s = component.as_os_str().to_string_lossy();
441 if !found_meta && glob::Pattern::escape(&s) == *s {
442 prefix.push(component);
443 } else {
444 found_meta = true;
445 }
446 }
447 let base = if prefix.as_os_str().is_empty() {
448 ".".to_string()
449 } else {
450 prefix.display().to_string()
451 };
452 (base, pattern_path.display().to_string())
453 };
454
455 let compiled = glob::Pattern::new(&match_pattern)?;
456
457 let walker = ignore::WalkBuilder::new(&base_dir)
458 .hidden(false)
459 .git_ignore(true)
460 .follow_links(false) .max_depth(Some(30)) .build();
463
464 for entry in walker {
465 if matches.len() >= limit {
466 break;
467 }
468 let entry = match entry {
469 Ok(e) => e,
470 Err(_) => continue,
471 };
472 let path = entry.path();
473 if compiled.matches_path(path) || compiled.matches(path.to_string_lossy().as_ref()) {
474 let display = path
475 .strip_prefix(&self.root)
476 .unwrap_or(path)
477 .display()
478 .to_string();
479 matches.push(display);
480 }
481 }
482
483 let truncated = matches.len() >= limit;
484 let output = matches.join("\n");
485
486 Ok(ToolResult::success(output)
487 .with_metadata("count", json!(matches.len()))
488 .with_metadata("truncated", json!(truncated)))
489 }
490}