Skip to main content

car_engine/
agent_basics.rs

1use 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(&regex::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}