Skip to main content

pawan/tools/
search.rs

1//! Search tools (glob and grep)
2
3use super::Tool;
4use async_trait::async_trait;
5use serde_json::{json, Value};
6use std::path::PathBuf;
7
8/// Tool for finding files by glob pattern
9pub struct GlobSearchTool {
10    workspace_root: PathBuf,
11}
12
13impl GlobSearchTool {
14    pub fn new(workspace_root: PathBuf) -> Self {
15        Self { workspace_root }
16    }
17}
18
19#[async_trait]
20impl Tool for GlobSearchTool {
21    fn name(&self) -> &str {
22        "glob_search"
23    }
24
25    fn description(&self) -> &str {
26        "Find files matching a glob pattern. Respects .gitignore. \
27         Examples: '**/*.rs', 'src/**/*.toml', 'Cargo.*'"
28    }
29
30    fn parameters_schema(&self) -> Value {
31        json!({
32            "type": "object",
33            "properties": {
34                "pattern": {
35                    "type": "string",
36                    "description": "Glob pattern to match files"
37                },
38                "path": {
39                    "type": "string",
40                    "description": "Directory to search in (optional, defaults to workspace root)"
41                },
42                "max_results": {
43                    "type": "integer",
44                    "description": "Maximum number of results (default: 100)"
45                }
46            },
47            "required": ["pattern"]
48        })
49    }
50
51    async fn execute(&self, args: Value) -> crate::Result<Value> {
52        let pattern = args["pattern"]
53            .as_str()
54            .ok_or_else(|| crate::PawanError::Tool("pattern is required".into()))?;
55
56        let base_path = args["path"]
57            .as_str()
58            .map(|p| self.workspace_root.join(p))
59            .unwrap_or_else(|| self.workspace_root.clone());
60
61        let max_results = args["max_results"].as_u64().unwrap_or(100) as usize;
62
63        // Use ignore crate to respect .gitignore
64        let mut builder = ignore::WalkBuilder::new(&base_path);
65        builder.hidden(false); // Include hidden files if explicitly matched
66
67        let mut matches = Vec::new();
68        let glob_matcher = glob::Pattern::new(pattern)
69            .map_err(|e| crate::PawanError::Tool(format!("Invalid glob pattern: {}", e)))?;
70
71        for result in builder.build() {
72            if matches.len() >= max_results {
73                break;
74            }
75
76            if let Ok(entry) = result {
77                let path = entry.path();
78                if path.is_file() {
79                    let relative = path.strip_prefix(&self.workspace_root).unwrap_or(path);
80                    let relative_str = relative.to_string_lossy();
81
82                    if glob_matcher.matches(&relative_str) {
83                        let metadata = path.metadata().ok();
84                        let size = metadata.as_ref().map(|m| m.len()).unwrap_or(0);
85                        let modified = metadata.and_then(|m| m.modified().ok()).map(|t| {
86                            t.duration_since(std::time::UNIX_EPOCH)
87                                .map(|d| d.as_secs())
88                                .unwrap_or(0)
89                        });
90                        matches.push(json!({
91                            "path": relative_str,
92                            "size": size,
93                            "modified": modified
94                        }));
95                    }
96                }
97            }
98        }
99
100        // Sort by modification time (newest first)
101        matches.sort_by(|a, b| {
102            let a_mod = a["modified"].as_u64().unwrap_or(0);
103            let b_mod = b["modified"].as_u64().unwrap_or(0);
104            b_mod.cmp(&a_mod)
105        });
106
107        Ok(json!({
108            "pattern": pattern,
109            "matches": matches,
110            "count": matches.len(),
111            "truncated": matches.len() >= max_results
112        }))
113    }
114}
115
116/// Tool for searching file contents
117pub struct GrepSearchTool {
118    workspace_root: PathBuf,
119}
120
121impl GrepSearchTool {
122    pub fn new(workspace_root: PathBuf) -> Self {
123        Self { workspace_root }
124    }
125}
126
127#[async_trait]
128impl Tool for GrepSearchTool {
129    fn name(&self) -> &str {
130        "grep_search"
131    }
132
133    fn description(&self) -> &str {
134        "Search file contents for a pattern. Supports regex. \
135         Returns file paths and line numbers with matches."
136    }
137
138    fn parameters_schema(&self) -> Value {
139        json!({
140            "type": "object",
141            "properties": {
142                "pattern": {
143                    "type": "string",
144                    "description": "Pattern to search for (supports regex)"
145                },
146                "path": {
147                    "type": "string",
148                    "description": "Directory to search in (optional, defaults to workspace root)"
149                },
150                "include": {
151                    "type": "string",
152                    "description": "File pattern to include (e.g., '*.rs', '*.{ts,tsx}')"
153                },
154                "max_results": {
155                    "type": "integer",
156                    "description": "Maximum number of matching files (default: 50)"
157                },
158                "context_lines": {
159                    "type": "integer",
160                    "description": "Lines of context around matches (default: 0)"
161                }
162            },
163            "required": ["pattern"]
164        })
165    }
166
167    async fn execute(&self, args: Value) -> crate::Result<Value> {
168        let pattern = args["pattern"]
169            .as_str()
170            .ok_or_else(|| crate::PawanError::Tool("pattern is required".into()))?;
171
172        let base_path = args["path"]
173            .as_str()
174            .map(|p| self.workspace_root.join(p))
175            .unwrap_or_else(|| self.workspace_root.clone());
176
177        let include = args["include"].as_str();
178        let max_results = args["max_results"].as_u64().unwrap_or(50) as usize;
179        let context_lines = args["context_lines"].as_u64().unwrap_or(0) as usize;
180
181        // Build regex
182        let regex = regex::Regex::new(pattern)
183            .map_err(|e| crate::PawanError::Tool(format!("Invalid regex: {}", e)))?;
184
185        // Build glob matcher for include filter
186        let include_matcher = include
187            .map(glob::Pattern::new)
188            .transpose()
189            .map_err(|e| crate::PawanError::Tool(format!("Invalid include pattern: {}", e)))?;
190
191        let mut file_matches = Vec::new();
192
193        // Walk directory
194        let mut builder = ignore::WalkBuilder::new(&base_path);
195        builder.hidden(false);
196
197        for result in builder.build() {
198            if file_matches.len() >= max_results {
199                break;
200            }
201
202            if let Ok(entry) = result {
203                let path = entry.path();
204                if !path.is_file() {
205                    continue;
206                }
207
208                let relative = path.strip_prefix(&self.workspace_root).unwrap_or(path);
209                let relative_str = relative.to_string_lossy();
210
211                // Check include filter
212                if let Some(ref matcher) = include_matcher {
213                    // Match against filename only
214                    let filename = path
215                        .file_name()
216                        .map(|n| n.to_string_lossy())
217                        .unwrap_or_default();
218                    if !matcher.matches(&filename) && !matcher.matches(&relative_str) {
219                        continue;
220                    }
221                }
222
223                // Read and search file
224                if let Ok(content) = std::fs::read_to_string(path) {
225                    let mut line_matches = Vec::new();
226                    let lines: Vec<&str> = content.lines().collect();
227
228                    for (line_num, line) in lines.iter().enumerate() {
229                        if regex.is_match(line) {
230                            let mut match_info = json!({
231                                "line": line_num + 1,
232                                "content": line.chars().take(200).collect::<String>()
233                            });
234
235                            // Add context if requested
236                            if context_lines > 0 {
237                                let start = line_num.saturating_sub(context_lines);
238                                let end = (line_num + context_lines + 1).min(lines.len());
239                                let context: Vec<String> = lines[start..end]
240                                    .iter()
241                                    .enumerate()
242                                    .map(|(i, l)| format!("{}: {}", start + i + 1, l))
243                                    .collect();
244                                match_info["context"] = json!(context);
245                            }
246
247                            line_matches.push(match_info);
248                        }
249                    }
250
251                    if !line_matches.is_empty() {
252                        file_matches.push(json!({
253                            "path": relative_str,
254                            "matches": line_matches,
255                            "match_count": line_matches.len()
256                        }));
257                    }
258                }
259            }
260        }
261
262        // Sort by match count (most matches first)
263        file_matches.sort_by(|a, b| {
264            let a_count = a["match_count"].as_u64().unwrap_or(0);
265            let b_count = b["match_count"].as_u64().unwrap_or(0);
266            b_count.cmp(&a_count)
267        });
268
269        let total_matches: u64 = file_matches
270            .iter()
271            .map(|f| f["match_count"].as_u64().unwrap_or(0))
272            .sum();
273
274        Ok(json!({
275            "pattern": pattern,
276            "files": file_matches,
277            "file_count": file_matches.len(),
278            "total_matches": total_matches,
279            "truncated": file_matches.len() >= max_results
280        }))
281    }
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287    use tempfile::TempDir;
288
289    #[tokio::test]
290    async fn test_glob_search() {
291        let temp_dir = TempDir::new().unwrap();
292        std::fs::write(temp_dir.path().join("file1.rs"), "rust code").unwrap();
293        std::fs::write(temp_dir.path().join("file2.rs"), "more rust").unwrap();
294        std::fs::write(temp_dir.path().join("file3.txt"), "text file").unwrap();
295
296        let tool = GlobSearchTool::new(temp_dir.path().to_path_buf());
297        let result = tool.execute(json!({"pattern": "*.rs"})).await.unwrap();
298
299        assert_eq!(result["count"], 2);
300    }
301
302    #[tokio::test]
303    async fn test_grep_search() {
304        let temp_dir = TempDir::new().unwrap();
305        std::fs::write(
306            temp_dir.path().join("test.rs"),
307            "fn main() {\n    println!(\"hello\");\n}",
308        )
309        .unwrap();
310
311        let tool = GrepSearchTool::new(temp_dir.path().to_path_buf());
312        let result = tool
313            .execute(json!({
314                "pattern": "println",
315                "include": "*.rs"
316            }))
317            .await
318            .unwrap();
319
320        assert_eq!(result["file_count"], 1);
321        assert_eq!(result["total_matches"], 1);
322    }
323}