codetether_agent/tool/
search.rs1use super::{Tool, ToolResult};
4use anyhow::Result;
5use async_trait::async_trait;
6use regex::Regex;
7use serde_json::{json, Value};
8use ignore::WalkBuilder;
9
10pub struct GrepTool;
12
13impl GrepTool {
14 pub fn new() -> Self {
15 Self
16 }
17}
18
19#[async_trait]
20impl Tool for GrepTool {
21 fn id(&self) -> &str {
22 "grep"
23 }
24
25 fn name(&self) -> &str {
26 "Grep Search"
27 }
28
29 fn description(&self) -> &str {
30 "Search for text or regex patterns in files. Respects .gitignore by default."
31 }
32
33 fn parameters(&self) -> Value {
34 json!({
35 "type": "object",
36 "properties": {
37 "pattern": {
38 "type": "string",
39 "description": "The text or regex pattern to search for"
40 },
41 "path": {
42 "type": "string",
43 "description": "Directory or file to search in (default: current directory)"
44 },
45 "is_regex": {
46 "type": "boolean",
47 "description": "Whether the pattern is a regex (default: false)"
48 },
49 "include": {
50 "type": "string",
51 "description": "Glob pattern to include files (e.g., *.rs)"
52 },
53 "limit": {
54 "type": "integer",
55 "description": "Maximum number of matches to return"
56 }
57 },
58 "required": ["pattern"]
59 })
60 }
61
62 async fn execute(&self, args: Value) -> Result<ToolResult> {
63 let pattern = args["pattern"]
64 .as_str()
65 .ok_or_else(|| anyhow::anyhow!("pattern is required"))?;
66 let search_path = args["path"].as_str().unwrap_or(".");
67 let is_regex = args["is_regex"].as_bool().unwrap_or(false);
68 let include = args["include"].as_str();
69 let limit = args["limit"].as_u64().unwrap_or(50) as usize;
70
71 let regex = if is_regex {
72 Regex::new(pattern)?
73 } else {
74 Regex::new(®ex::escape(pattern))?
75 };
76
77 let mut results = Vec::new();
78 let mut walker = WalkBuilder::new(search_path);
79 walker.hidden(false).git_ignore(true);
80
81 for entry in walker.build() {
82 if results.len() >= limit {
83 break;
84 }
85
86 let entry = match entry {
87 Ok(e) => e,
88 Err(_) => continue,
89 };
90
91 if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
92 continue;
93 }
94
95 let path = entry.path();
96
97 if let Some(include_pattern) = include {
99 if !glob::Pattern::new(include_pattern)
100 .map(|p| p.matches_path(path))
101 .unwrap_or(false)
102 {
103 continue;
104 }
105 }
106
107 if let Ok(content) = tokio::fs::read_to_string(path).await {
109 for (line_num, line) in content.lines().enumerate() {
110 if results.len() >= limit {
111 break;
112 }
113
114 if regex.is_match(line) {
115 results.push(format!(
116 "{}:{}: {}",
117 path.display(),
118 line_num + 1,
119 line.trim()
120 ));
121 }
122 }
123 }
124 }
125
126 let truncated = results.len() >= limit;
127 let output = results.join("\n");
128
129 Ok(ToolResult::success(output)
130 .with_metadata("count", json!(results.len()))
131 .with_metadata("truncated", json!(truncated)))
132 }
133}