Skip to main content

adk_devtools/tools/
grep.rs

1//! `grep` — regex content search across workspace files.
2
3use std::sync::Arc;
4
5use adk_core::{Result, Tool, ToolContext};
6use async_trait::async_trait;
7use regex::Regex;
8use serde_json::{Value, json};
9use walkdir::WalkDir;
10
11use crate::error::DevToolError;
12use crate::tools::read::require_str;
13use crate::workspace::Workspace;
14
15const MAX_MATCHES: usize = 500;
16const MAX_FILE_BYTES: u64 = 5 * 1_048_576;
17const SKIP_DIRS: &[&str] = &[".git", "target", "node_modules", ".venv", "dist", "build"];
18
19/// Searches file contents for a regular expression, returning `path:line: text`
20/// matches. Skips common build/VCS directories and binary/oversized files.
21pub struct GrepTool {
22    workspace: Workspace,
23}
24
25impl GrepTool {
26    /// Create a `grep` tool bound to `workspace`.
27    pub fn new(workspace: Workspace) -> Self {
28        Self { workspace }
29    }
30}
31
32#[async_trait]
33impl Tool for GrepTool {
34    fn name(&self) -> &str {
35        "grep"
36    }
37
38    fn description(&self) -> &str {
39        "Search file contents for a regular expression. Returns matching lines as \
40         'path:line: text'. Optionally restrict to a sub-path and/or a glob over file names."
41    }
42
43    fn is_read_only(&self) -> bool {
44        true
45    }
46
47    fn is_concurrency_safe(&self) -> bool {
48        true
49    }
50
51    fn parameters_schema(&self) -> Option<Value> {
52        Some(json!({
53            "type": "object",
54            "properties": {
55                "pattern": { "type": "string", "description": "Regular expression to search for." },
56                "path": { "type": "string", "description": "Optional sub-directory to search within." },
57                "glob": { "type": "string", "description": "Optional glob over file names, e.g. '*.rs'." },
58                "case_insensitive": { "type": "boolean", "description": "Case-insensitive match (default false)." }
59            },
60            "required": ["pattern"]
61        }))
62    }
63
64    async fn execute(&self, _ctx: Arc<dyn ToolContext>, args: Value) -> Result<Value> {
65        let pattern = require_str(&args, "pattern")?;
66        let case_insensitive =
67            args.get("case_insensitive").and_then(Value::as_bool).unwrap_or(false);
68        let name_glob = args
69            .get("glob")
70            .and_then(Value::as_str)
71            .map(glob::Pattern::new)
72            .transpose()
73            .map_err(|e| DevToolError::Other(format!("invalid glob: {e}")))?;
74
75        let re = regex::RegexBuilder::new(&pattern)
76            .case_insensitive(case_insensitive)
77            .build()
78            .map_err(|e| DevToolError::Other(format!("invalid regex: {e}")))?;
79
80        let base = match args.get("path").and_then(Value::as_str) {
81            Some(sub) => self.workspace.resolve(sub)?,
82            None => self.workspace.root().to_path_buf(),
83        };
84
85        let (matches, truncated) = search(&base, &re, name_glob.as_ref(), &self.workspace);
86
87        Ok(json!({
88            "pattern": pattern,
89            "matches": matches,
90            "count": matches.len(),
91            "truncated": truncated,
92        }))
93    }
94}
95
96fn search(
97    base: &std::path::Path,
98    re: &Regex,
99    name_glob: Option<&glob::Pattern>,
100    ws: &Workspace,
101) -> (Vec<String>, bool) {
102    let mut out = Vec::new();
103    let walker = WalkDir::new(base)
104        .into_iter()
105        .filter_entry(|e| !e.file_name().to_str().map(|n| SKIP_DIRS.contains(&n)).unwrap_or(false));
106
107    for entry in walker.filter_map(std::result::Result::ok) {
108        if !entry.file_type().is_file() {
109            continue;
110        }
111        let path = entry.path();
112        if let Some(g) = name_glob {
113            let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
114            if !g.matches(name) {
115                continue;
116            }
117        }
118        if entry.metadata().map(|m| m.len() > MAX_FILE_BYTES).unwrap_or(true) {
119            continue;
120        }
121        let Ok(contents) = std::fs::read_to_string(path) else {
122            continue; // skip binary / non-utf8
123        };
124        let display = ws.display(path);
125        for (idx, line) in contents.lines().enumerate() {
126            if re.is_match(line) {
127                out.push(format!("{}:{}: {}", display, idx + 1, line.trim_end()));
128                if out.len() >= MAX_MATCHES {
129                    return (out, true);
130                }
131            }
132        }
133    }
134    (out, false)
135}