car_engine/
agent_basics.rs1use crate::registry::{ToolEntry, ToolPermission};
2use regex::Regex;
3use serde_json::{json, Value};
4use std::fs;
5use std::path::{Path, PathBuf};
6
7const MAX_FILE_BYTES: usize = 512 * 1024;
8
9pub fn entries() -> Vec<ToolEntry> {
10 vec![
11 ToolEntry::builtin(car_ir::builtins::read_file()).with_category("filesystem"),
12 ToolEntry::builtin(car_ir::builtins::list_dir()).with_category("filesystem"),
13 ToolEntry::builtin(car_ir::builtins::find_files()).with_category("filesystem"),
14 ToolEntry::builtin(car_ir::builtins::grep_files()).with_category("filesystem"),
15 ToolEntry::builtin(car_ir::builtins::calculate()).with_category("utility"),
16 ToolEntry::builtin(car_ir::builtins::write_file())
17 .with_permission(ToolPermission::AskUser)
18 .with_side_effects(true)
19 .with_category("filesystem"),
20 ToolEntry::builtin(car_ir::builtins::edit_file())
21 .with_permission(ToolPermission::AskUser)
22 .with_side_effects(true)
23 .with_category("filesystem"),
24 ]
25}
26
27pub async fn execute(tool: &str, params: &Value) -> Option<Result<Value, String>> {
28 let result = match tool {
29 "read_file" => exec_read_file(params),
30 "write_file" => exec_write_file(params),
31 "edit_file" => exec_edit_file(params),
32 "list_dir" => exec_list_dir(params),
33 "find_files" => exec_find_files(params),
34 "grep_files" => exec_grep_files(params),
35 "calculate" => exec_calculate(params),
36 _ => return None,
37 };
38 Some(result)
39}
40
41fn resolve_path(path: &str) -> Result<PathBuf, String> {
42 let candidate = PathBuf::from(path);
43 if candidate.is_absolute() {
44 Ok(candidate)
45 } else {
46 std::env::current_dir()
47 .map(|cwd| cwd.join(candidate))
48 .map_err(|e| format!("failed to resolve working directory: {e}"))
49 }
50}
51
52fn exec_read_file(params: &Value) -> Result<Value, String> {
53 let path = params
54 .get("path")
55 .and_then(|v| v.as_str())
56 .ok_or("missing 'path' parameter")?;
57 let offset = params.get("offset").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
58 let limit = params
59 .get("limit")
60 .and_then(|v| v.as_u64())
61 .map(|v| v as usize);
62 let full_path = resolve_path(path)?;
63
64 let content = fs::read_to_string(&full_path)
65 .map_err(|e| format!("failed to read file '{}': {e}", full_path.display()))?;
66 let size_bytes = content.len();
67 let total_lines = content.lines().count();
68
69 let returned = if offset > 0 || limit.is_some() {
70 let lines: Vec<&str> = content.lines().collect();
71 let start = offset.min(lines.len());
72 let end = limit
73 .map(|line_count| (start + line_count).min(lines.len()))
74 .unwrap_or(lines.len());
75 lines[start..end].join("\n")
76 } else {
77 content
78 };
79
80 Ok(json!({
81 "path": full_path.display().to_string(),
82 "content": returned,
83 "size_bytes": size_bytes,
84 "total_lines": total_lines,
85 }))
86}
87
88fn exec_write_file(params: &Value) -> Result<Value, String> {
89 let path = params
90 .get("path")
91 .and_then(|v| v.as_str())
92 .ok_or("missing 'path' parameter")?;
93 let content = params
94 .get("content")
95 .and_then(|v| v.as_str())
96 .ok_or("missing 'content' parameter")?;
97 let append = params
98 .get("append")
99 .and_then(|v| v.as_bool())
100 .unwrap_or(false);
101 let full_path = resolve_path(path)?;
102
103 if let Some(parent) = full_path.parent() {
104 fs::create_dir_all(parent)
105 .map_err(|e| format!("failed to create parent dir '{}': {e}", parent.display()))?;
106 }
107
108 if append {
109 use std::io::Write;
110 let mut file = fs::OpenOptions::new()
111 .create(true)
112 .append(true)
113 .open(&full_path)
114 .map_err(|e| format!("failed to open file '{}': {e}", full_path.display()))?;
115 file.write_all(content.as_bytes())
116 .map_err(|e| format!("failed to append file '{}': {e}", full_path.display()))?;
117 } else {
118 fs::write(&full_path, content)
119 .map_err(|e| format!("failed to write file '{}': {e}", full_path.display()))?;
120 }
121
122 Ok(json!({
123 "path": full_path.display().to_string(),
124 "bytes_written": content.len(),
125 "append": append,
126 }))
127}
128
129fn exec_edit_file(params: &Value) -> Result<Value, String> {
130 let path = params
131 .get("path")
132 .and_then(|v| v.as_str())
133 .ok_or("missing 'path' parameter")?;
134 let old_text = params
135 .get("old_text")
136 .and_then(|v| v.as_str())
137 .ok_or("missing 'old_text' parameter")?;
138 let new_text = params
139 .get("new_text")
140 .and_then(|v| v.as_str())
141 .ok_or("missing 'new_text' parameter")?;
142 let full_path = resolve_path(path)?;
143
144 let content = fs::read_to_string(&full_path)
145 .map_err(|e| format!("failed to read file '{}': {e}", full_path.display()))?;
146 let count = content.matches(old_text).count();
147 if count == 0 {
148 return Err(format!("old_text not found in '{}'", full_path.display()));
149 }
150 if count > 1 {
151 return Err(format!(
152 "old_text found {count} times in '{}' and must match uniquely",
153 full_path.display()
154 ));
155 }
156
157 let new_content = content.replacen(old_text, new_text, 1);
158 fs::write(&full_path, new_content)
159 .map_err(|e| format!("failed to write file '{}': {e}", full_path.display()))?;
160
161 Ok(json!({
162 "edited": full_path.display().to_string(),
163 "diff_summary": format!(
164 "replaced {} lines with {} lines",
165 old_text.lines().count(),
166 new_text.lines().count()
167 ),
168 }))
169}
170
171fn exec_list_dir(params: &Value) -> Result<Value, String> {
172 let path = params.get("path").and_then(|v| v.as_str()).unwrap_or(".");
173 let full_path = resolve_path(path)?;
174 let mut entries = Vec::new();
175
176 let read_dir = fs::read_dir(&full_path)
177 .map_err(|e| format!("failed to read dir '{}': {e}", full_path.display()))?;
178 for entry in read_dir {
179 let entry = entry.map_err(|e| format!("failed to read dir entry: {e}"))?;
180 let file_name = entry.file_name().to_string_lossy().to_string();
181 if should_skip_name(&file_name) {
182 continue;
183 }
184 let metadata = entry
185 .metadata()
186 .map_err(|e| format!("failed to read metadata for '{}': {e}", file_name))?;
187 entries.push(json!({
188 "name": file_name,
189 "path": entry.path().display().to_string(),
190 "is_dir": metadata.is_dir(),
191 "size_bytes": if metadata.is_file() { Some(metadata.len()) } else { None::<u64> },
192 }));
193 }
194
195 Ok(json!({
196 "path": full_path.display().to_string(),
197 "entries": entries,
198 }))
199}
200
201fn exec_find_files(params: &Value) -> Result<Value, String> {
202 let pattern = params
203 .get("pattern")
204 .and_then(|v| v.as_str())
205 .ok_or("missing 'pattern' parameter")?;
206 let root = params.get("path").and_then(|v| v.as_str()).unwrap_or(".");
207 let max_results = params
208 .get("max_results")
209 .and_then(|v| v.as_u64())
210 .unwrap_or(50) as usize;
211 let root_path = resolve_path(root)?;
212 let matcher = glob_to_regex(pattern)?;
213 let mut files = Vec::new();
214
215 walk_files(&root_path, &mut |path| {
216 if files.len() >= max_results {
217 return;
218 }
219 if let Some(name) = path.file_name().and_then(|v| v.to_str()) {
220 if matcher.is_match(name) {
221 files.push(path.display().to_string());
222 }
223 }
224 })?;
225
226 Ok(json!({
227 "files": files,
228 "count": files.len(),
229 "truncated": files.len() >= max_results,
230 }))
231}
232
233fn exec_grep_files(params: &Value) -> Result<Value, String> {
234 let pattern = params
235 .get("pattern")
236 .and_then(|v| v.as_str())
237 .ok_or("missing 'pattern' parameter")?;
238 let root = params.get("path").and_then(|v| v.as_str()).unwrap_or(".");
239 let max_results = params
240 .get("max_results")
241 .and_then(|v| v.as_u64())
242 .unwrap_or(50) as usize;
243 let root_path = resolve_path(root)?;
244 let regex = Regex::new(pattern).map_err(|e| format!("invalid regex pattern: {e}"))?;
245 let mut matches = Vec::new();
246
247 walk_files(&root_path, &mut |path| {
248 if matches.len() >= max_results || !is_text_file(path) {
249 return;
250 }
251 let Ok(content) = fs::read_to_string(path) else {
252 return;
253 };
254 if content.len() > MAX_FILE_BYTES {
255 return;
256 }
257 for (idx, line) in content.lines().enumerate() {
258 if regex.is_match(line) {
259 matches.push(json!({
260 "path": path.display().to_string(),
261 "line": idx + 1,
262 "text": line,
263 }));
264 if matches.len() >= max_results {
265 break;
266 }
267 }
268 }
269 })?;
270
271 Ok(json!({
272 "matches": matches,
273 "count": matches.len(),
274 "truncated": matches.len() >= max_results,
275 }))
276}
277
278fn exec_calculate(params: &Value) -> Result<Value, String> {
279 let expression = params
280 .get("expression")
281 .and_then(|v| v.as_str())
282 .ok_or("missing 'expression' parameter")?;
283 let result =
284 meval::eval_str(expression).map_err(|e| format!("failed to evaluate expression: {e}"))?;
285 Ok(json!({ "result": result }))
286}
287
288fn should_skip_name(name: &str) -> bool {
289 name.starts_with('.') || matches!(name, "node_modules" | "__pycache__" | "target")
290}
291
292fn is_text_file(path: &Path) -> bool {
293 matches!(
294 path.extension().and_then(|v| v.to_str()),
295 Some(
296 "c" | "cc"
297 | "cpp"
298 | "cs"
299 | "css"
300 | "go"
301 | "h"
302 | "html"
303 | "ini"
304 | "java"
305 | "js"
306 | "json"
307 | "jsx"
308 | "kt"
309 | "md"
310 | "py"
311 | "rb"
312 | "rs"
313 | "sh"
314 | "sql"
315 | "toml"
316 | "ts"
317 | "tsx"
318 | "txt"
319 | "xml"
320 | "yaml"
321 | "yml"
322 )
323 )
324}
325
326fn walk_files(root: &Path, visit: &mut dyn FnMut(&Path)) -> Result<(), String> {
327 if root.is_file() {
328 visit(root);
329 return Ok(());
330 }
331
332 let read_dir =
333 fs::read_dir(root).map_err(|e| format!("failed to read dir '{}': {e}", root.display()))?;
334 for entry in read_dir {
335 let entry = entry.map_err(|e| format!("failed to read dir entry: {e}"))?;
336 let path = entry.path();
337 let file_name = entry.file_name().to_string_lossy().to_string();
338 if should_skip_name(&file_name) {
339 continue;
340 }
341 let metadata = entry
342 .metadata()
343 .map_err(|e| format!("failed to read metadata for '{}': {e}", path.display()))?;
344 if metadata.is_dir() {
345 walk_files(&path, visit)?;
346 } else if metadata.is_file() {
347 visit(&path);
348 }
349 }
350 Ok(())
351}
352
353fn glob_to_regex(pattern: &str) -> Result<Regex, String> {
354 let mut escaped = String::from("^");
355 for ch in pattern.chars() {
356 match ch {
357 '*' => escaped.push_str(".*"),
358 '?' => escaped.push('.'),
359 _ => escaped.push_str(®ex::escape(&ch.to_string())),
360 }
361 }
362 escaped.push('$');
363 Regex::new(&escaped).map_err(|e| format!("invalid glob pattern: {e}"))
364}
365
366#[cfg(test)]
367mod tests {
368 use super::*;
369
370 #[test]
371 fn glob_patterns_match_file_names() {
372 let regex = glob_to_regex("*.rs").unwrap();
373 assert!(regex.is_match("lib.rs"));
374 assert!(!regex.is_match("lib.ts"));
375 }
376}