Skip to main content

dot/tools/
grep.rs

1use anyhow::{Context, Result};
2use regex::Regex;
3use serde_json::Value;
4use std::fs;
5use std::path::Path;
6
7use super::Tool;
8
9const MAX_RESULTS: usize = 100;
10
11pub struct GrepTool;
12
13impl Tool for GrepTool {
14    fn name(&self) -> &str {
15        "grep"
16    }
17
18    fn description(&self) -> &str {
19        "Search file contents using regex patterns. Returns matching lines with file paths and line numbers. More precise than search_files."
20    }
21
22    fn input_schema(&self) -> Value {
23        serde_json::json!({
24            "type": "object",
25            "properties": {
26                "pattern": {
27                    "type": "string",
28                    "description": "Regex pattern to search for"
29                },
30                "path": {
31                    "type": "string",
32                    "description": "Directory to search in"
33                },
34                "include": {
35                    "type": "string",
36                    "description": "File glob filter (e.g. '*.rs', '*.{ts,tsx}')"
37                }
38            },
39            "required": ["pattern", "path"]
40        })
41    }
42
43    fn execute(&self, input: Value) -> Result<String> {
44        let pattern = input["pattern"]
45            .as_str()
46            .context("Missing required parameter 'pattern'")?;
47        let path = input["path"]
48            .as_str()
49            .context("Missing required parameter 'path'")?;
50        let include = input["include"].as_str().unwrap_or("");
51        tracing::debug!("grep: '{}' in {}", pattern, path);
52
53        let re = Regex::new(pattern).with_context(|| format!("invalid regex: {}", pattern))?;
54
55        let mut results = Vec::new();
56        grep_recursive(Path::new(path), &re, include, &mut results);
57
58        if results.is_empty() {
59            Ok(format!("No matches for '{}' in '{}'", pattern, path))
60        } else {
61            let truncated = results.len() >= MAX_RESULTS;
62            let mut output = results.join("\n");
63            if truncated {
64                output.push_str(&format!("\n... (truncated at {} matches)", MAX_RESULTS));
65            }
66            Ok(output)
67        }
68    }
69}
70
71fn grep_recursive(dir: &Path, re: &Regex, include: &str, results: &mut Vec<String>) {
72    if results.len() >= MAX_RESULTS {
73        return;
74    }
75
76    let entries = match fs::read_dir(dir) {
77        Ok(e) => e,
78        Err(_) => return,
79    };
80
81    for entry in entries {
82        if results.len() >= MAX_RESULTS {
83            return;
84        }
85
86        let entry = match entry {
87            Ok(e) => e,
88            Err(_) => continue,
89        };
90
91        let path = entry.path();
92        let metadata = match entry.metadata() {
93            Ok(m) => m,
94            Err(_) => continue,
95        };
96
97        if metadata.is_dir() {
98            let name = path.file_name().unwrap_or_default().to_string_lossy();
99            if name.starts_with('.')
100                || name == "target"
101                || name == "node_modules"
102                || name == "__pycache__"
103                || name == ".git"
104            {
105                continue;
106            }
107            grep_recursive(&path, re, include, results);
108        } else if metadata.is_file() {
109            let name = path
110                .file_name()
111                .unwrap_or_default()
112                .to_string_lossy()
113                .to_string();
114            if !include.is_empty() && !matches_include(&name, include) {
115                continue;
116            }
117
118            let content = match fs::read_to_string(&path) {
119                Ok(c) => c,
120                Err(_) => continue,
121            };
122
123            for (i, line) in content.lines().enumerate() {
124                if results.len() >= MAX_RESULTS {
125                    return;
126                }
127                if re.is_match(line) {
128                    results.push(format!("{}:{}: {}", path.display(), i + 1, line.trim()));
129                }
130            }
131        }
132    }
133}
134
135fn matches_include(filename: &str, include: &str) -> bool {
136    if let Some(ext_pat) = include.strip_prefix("*.") {
137        if ext_pat.starts_with('{') && ext_pat.ends_with('}') {
138            let inner = &ext_pat[1..ext_pat.len() - 1];
139            return inner
140                .split(',')
141                .any(|ext| filename.ends_with(&format!(".{}", ext.trim())));
142        }
143        return filename.ends_with(&format!(".{}", ext_pat));
144    }
145    filename == include
146}