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 regex::Regex;
7use serde_json::{json, Value};
8use ignore::WalkBuilder;
9
10/// Search for text in files
11pub 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(&regex::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            // Check include pattern
98            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            // Read and search file
108            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}