adk_devtools/tools/
grep.rs1use 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
19pub struct GrepTool {
22 workspace: Workspace,
23}
24
25impl GrepTool {
26 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; };
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}