Skip to main content

codetether_agent/tool/
search.rs

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