use crate::registry::{ToolEntry, ToolPermission};
use crate::substrate::Substrate;
use regex::Regex;
use serde_json::{json, Value};
use std::path::{Path, PathBuf};
use std::sync::Arc;
const MAX_FILE_BYTES: usize = 512 * 1024;
pub fn entries() -> Vec<ToolEntry> {
vec![
ToolEntry::builtin(car_ir::builtins::read_file()).with_category("filesystem"),
ToolEntry::builtin(car_ir::builtins::list_dir()).with_category("filesystem"),
ToolEntry::builtin(car_ir::builtins::find_files()).with_category("filesystem"),
ToolEntry::builtin(car_ir::builtins::grep_files()).with_category("filesystem"),
ToolEntry::builtin(car_ir::builtins::calculate()).with_category("utility"),
ToolEntry::builtin(car_ir::builtins::write_file())
.with_permission(ToolPermission::AskUser)
.with_side_effects(true)
.with_category("filesystem"),
ToolEntry::builtin(car_ir::builtins::edit_file())
.with_permission(ToolPermission::AskUser)
.with_side_effects(true)
.with_category("filesystem"),
]
}
pub async fn execute(
substrate: &Arc<dyn Substrate>,
tool: &str,
params: &Value,
) -> Option<Result<Value, String>> {
let result = match tool {
"read_file" => exec_read_file(substrate, params).await,
"write_file" => exec_write_file(substrate, params).await,
"edit_file" => exec_edit_file(substrate, params).await,
"list_dir" => exec_list_dir(substrate, params).await,
"find_files" => exec_find_files(substrate, params).await,
"grep_files" => exec_grep_files(substrate, params).await,
"calculate" => exec_calculate(params),
_ => return None,
};
Some(result)
}
async fn exec_read_file(substrate: &Arc<dyn Substrate>, params: &Value) -> Result<Value, String> {
let path = params
.get("path")
.and_then(|v| v.as_str())
.ok_or("missing 'path' parameter")?;
let offset = params.get("offset").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
let limit = params
.get("limit")
.and_then(|v| v.as_u64())
.map(|v| v as usize);
let content = substrate.read_text(path).await?;
let size_bytes = content.len();
let total_lines = content.lines().count();
let returned = if offset > 0 || limit.is_some() {
let lines: Vec<&str> = content.lines().collect();
let start = offset.min(lines.len());
let end = limit
.map(|line_count| (start + line_count).min(lines.len()))
.unwrap_or(lines.len());
lines[start..end].join("\n")
} else {
content
};
Ok(json!({
"path": substrate.display_path(path),
"content": returned,
"size_bytes": size_bytes,
"total_lines": total_lines,
}))
}
async fn exec_write_file(substrate: &Arc<dyn Substrate>, params: &Value) -> Result<Value, String> {
let path = params
.get("path")
.and_then(|v| v.as_str())
.ok_or("missing 'path' parameter")?;
let content = params
.get("content")
.and_then(|v| v.as_str())
.ok_or("missing 'content' parameter")?;
let append = params
.get("append")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if append {
let existing = substrate.read_text(path).await.unwrap_or_default();
let mut combined = existing;
combined.push_str(content);
substrate.write_text(path, &combined).await?;
} else {
substrate.write_text(path, content).await?;
}
Ok(json!({
"path": substrate.display_path(path),
"bytes_written": content.len(),
"append": append,
}))
}
async fn exec_edit_file(substrate: &Arc<dyn Substrate>, params: &Value) -> Result<Value, String> {
let path = params
.get("path")
.and_then(|v| v.as_str())
.ok_or("missing 'path' parameter")?;
let old_text = params
.get("old_text")
.and_then(|v| v.as_str())
.ok_or("missing 'old_text' parameter")?;
let new_text = params
.get("new_text")
.and_then(|v| v.as_str())
.ok_or("missing 'new_text' parameter")?;
let content = substrate.read_text(path).await?;
let count = content.matches(old_text).count();
if count == 0 {
return Err(format!(
"old_text not found in '{}'",
substrate.display_path(path)
));
}
if count > 1 {
return Err(format!(
"old_text found {count} times in '{}' and must match uniquely",
substrate.display_path(path)
));
}
let new_content = content.replacen(old_text, new_text, 1);
substrate.write_text(path, &new_content).await?;
Ok(json!({
"edited": substrate.display_path(path),
"diff_summary": format!(
"replaced {} lines with {} lines",
old_text.lines().count(),
new_text.lines().count()
),
}))
}
async fn exec_list_dir(substrate: &Arc<dyn Substrate>, params: &Value) -> Result<Value, String> {
let path = params.get("path").and_then(|v| v.as_str()).unwrap_or(".");
if !substrate.is_local() {
return list_dir_via_command(substrate, path).await;
}
let full_path = local_resolve(path)?;
let mut entries = Vec::new();
let read_dir = std::fs::read_dir(&full_path)
.map_err(|e| format!("failed to read dir '{}': {e}", full_path.display()))?;
for entry in read_dir {
let entry = entry.map_err(|e| format!("failed to read dir entry: {e}"))?;
let file_name = entry.file_name().to_string_lossy().to_string();
if should_skip_name(&file_name) {
continue;
}
let metadata = entry
.metadata()
.map_err(|e| format!("failed to read metadata for '{}': {e}", file_name))?;
entries.push(json!({
"name": file_name,
"path": entry.path().display().to_string(),
"is_dir": metadata.is_dir(),
"size_bytes": if metadata.is_file() { Some(metadata.len()) } else { None::<u64> },
}));
}
Ok(json!({
"path": full_path.display().to_string(),
"entries": entries,
}))
}
async fn exec_find_files(substrate: &Arc<dyn Substrate>, params: &Value) -> Result<Value, String> {
let pattern = params
.get("pattern")
.and_then(|v| v.as_str())
.ok_or("missing 'pattern' parameter")?;
let root = params.get("path").and_then(|v| v.as_str()).unwrap_or(".");
let max_results = params
.get("max_results")
.and_then(|v| v.as_u64())
.unwrap_or(50) as usize;
if !substrate.is_local() {
return find_files_via_command(substrate, pattern, root, max_results).await;
}
let root_path = local_resolve(root)?;
let matcher = glob_to_regex(pattern)?;
let mut files = Vec::new();
walk_files(&root_path, &mut |path| {
if files.len() >= max_results {
return;
}
if let Some(name) = path.file_name().and_then(|v| v.to_str()) {
if matcher.is_match(name) {
files.push(path.display().to_string());
}
}
})?;
Ok(json!({
"files": files,
"count": files.len(),
"truncated": files.len() >= max_results,
}))
}
async fn exec_grep_files(substrate: &Arc<dyn Substrate>, params: &Value) -> Result<Value, String> {
let pattern = params
.get("pattern")
.and_then(|v| v.as_str())
.ok_or("missing 'pattern' parameter")?;
let root = params.get("path").and_then(|v| v.as_str()).unwrap_or(".");
let max_results = params
.get("max_results")
.and_then(|v| v.as_u64())
.unwrap_or(50) as usize;
if !substrate.is_local() {
return grep_files_via_command(substrate, pattern, root, max_results).await;
}
let root_path = local_resolve(root)?;
let regex = Regex::new(pattern).map_err(|e| format!("invalid regex pattern: {e}"))?;
let mut matches = Vec::new();
walk_files(&root_path, &mut |path| {
if matches.len() >= max_results || !is_text_file(path) {
return;
}
let Ok(content) = std::fs::read_to_string(path) else {
return;
};
if content.len() > MAX_FILE_BYTES {
return;
}
for (idx, line) in content.lines().enumerate() {
if regex.is_match(line) {
matches.push(json!({
"path": path.display().to_string(),
"line": idx + 1,
"text": line,
}));
if matches.len() >= max_results {
break;
}
}
}
})?;
Ok(json!({
"matches": matches,
"count": matches.len(),
"truncated": matches.len() >= max_results,
}))
}
fn exec_calculate(params: &Value) -> Result<Value, String> {
let expression = params
.get("expression")
.and_then(|v| v.as_str())
.ok_or("missing 'expression' parameter")?;
let mut ns = |name: &str, args: Vec<f64>| -> Option<f64> {
match (name, args.as_slice()) {
("sqrt", [x]) => Some(x.sqrt()),
("ln", [x]) => Some(x.ln()),
("pi", []) => Some(std::f64::consts::PI),
("e", []) => Some(std::f64::consts::E),
_ => None,
}
};
let result = fasteval::ez_eval(expression, &mut ns)
.map_err(|e| format!("failed to evaluate expression: {e}"))?;
Ok(json!({ "result": result }))
}
fn local_resolve(path: &str) -> Result<PathBuf, String> {
crate::substrate::LocalSubstrate::resolve_path(path)
}
fn should_skip_name(name: &str) -> bool {
name.starts_with('.') || matches!(name, "node_modules" | "__pycache__" | "target")
}
fn is_text_file(path: &Path) -> bool {
matches!(
path.extension().and_then(|v| v.to_str()),
Some(
"c" | "cc"
| "cpp"
| "cs"
| "css"
| "go"
| "h"
| "html"
| "ini"
| "java"
| "js"
| "json"
| "jsx"
| "kt"
| "md"
| "py"
| "rb"
| "rs"
| "sh"
| "sql"
| "toml"
| "ts"
| "tsx"
| "txt"
| "xml"
| "yaml"
| "yml"
)
)
}
fn walk_files(root: &Path, visit: &mut dyn FnMut(&Path)) -> Result<(), String> {
if root.is_file() {
visit(root);
return Ok(());
}
let read_dir = std::fs::read_dir(root)
.map_err(|e| format!("failed to read dir '{}': {e}", root.display()))?;
for entry in read_dir {
let entry = entry.map_err(|e| format!("failed to read dir entry: {e}"))?;
let path = entry.path();
let file_name = entry.file_name().to_string_lossy().to_string();
if should_skip_name(&file_name) {
continue;
}
let metadata = entry
.metadata()
.map_err(|e| format!("failed to read metadata for '{}': {e}", path.display()))?;
if metadata.is_dir() {
walk_files(&path, visit)?;
} else if metadata.is_file() {
visit(&path);
}
}
Ok(())
}
fn glob_to_regex(pattern: &str) -> Result<Regex, String> {
let mut escaped = String::from("^");
for ch in pattern.chars() {
match ch {
'*' => escaped.push_str(".*"),
'?' => escaped.push('.'),
_ => escaped.push_str(®ex::escape(&ch.to_string())),
}
}
escaped.push('$');
Regex::new(&escaped).map_err(|e| format!("invalid glob pattern: {e}"))
}
fn shell_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', "'\\''"))
}
async fn list_dir_via_command(substrate: &Arc<dyn Substrate>, path: &str) -> Result<Value, String> {
let cmd = format!("ls -1Ap {}", shell_quote(path));
let out = substrate.run_command(&cmd, Some(30.0)).await?;
let entries: Vec<Value> = out
.stdout
.lines()
.filter(|l| !l.is_empty())
.filter(|name| {
let bare = name.trim_end_matches('/');
!should_skip_name(bare)
})
.map(|name| {
let is_dir = name.ends_with('/');
let bare = name.trim_end_matches('/');
json!({
"name": bare,
"path": format!("{}/{}", path.trim_end_matches('/'), bare),
"is_dir": is_dir,
"size_bytes": Value::Null,
})
})
.collect();
Ok(json!({ "path": path, "entries": entries }))
}
async fn find_files_via_command(
substrate: &Arc<dyn Substrate>,
pattern: &str,
root: &str,
max_results: usize,
) -> Result<Value, String> {
let cmd = format!(
"find {} -name {} -type f",
shell_quote(root),
shell_quote(pattern)
);
let out = substrate.run_command(&cmd, Some(30.0)).await?;
let mut files: Vec<String> = out
.stdout
.lines()
.filter(|l| !l.is_empty())
.map(|s| s.to_string())
.collect();
let truncated = files.len() > max_results;
files.truncate(max_results);
Ok(json!({
"files": files,
"count": files.len(),
"truncated": truncated,
}))
}
async fn grep_files_via_command(
substrate: &Arc<dyn Substrate>,
pattern: &str,
root: &str,
max_results: usize,
) -> Result<Value, String> {
let cmd = format!(
"grep -rnE {} {}",
shell_quote(pattern),
shell_quote(root)
);
let out = substrate.run_command(&cmd, Some(30.0)).await?;
let mut matches = Vec::new();
for line in out.stdout.lines() {
if matches.len() >= max_results {
break;
}
let mut parts = line.splitn(3, ':');
let (Some(p), Some(ln), Some(text)) = (parts.next(), parts.next(), parts.next()) else {
continue;
};
let Ok(line_no) = ln.parse::<usize>() else {
continue;
};
matches.push(json!({ "path": p, "line": line_no, "text": text }));
}
let truncated = matches.len() >= max_results;
Ok(json!({
"matches": matches,
"count": matches.len(),
"truncated": truncated,
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn glob_patterns_match_file_names() {
let regex = glob_to_regex("*.rs").unwrap();
assert!(regex.is_match("lib.rs"));
assert!(!regex.is_match("lib.ts"));
}
#[tokio::test]
async fn read_write_roundtrip_against_local_substrate() {
let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
let dir = std::env::temp_dir().join(format!(
"car-agent-basics-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("note.txt").to_string_lossy().to_string();
let w = exec_write_file(&substrate, &json!({ "path": path, "content": "abc" }))
.await
.unwrap();
assert_eq!(w["bytes_written"], 3);
let r = exec_read_file(&substrate, &json!({ "path": path }))
.await
.unwrap();
assert_eq!(r["content"], "abc");
assert_eq!(r["size_bytes"], 3);
exec_write_file(
&substrate,
&json!({ "path": path, "content": "def", "append": true }),
)
.await
.unwrap();
let r2 = exec_read_file(&substrate, &json!({ "path": path }))
.await
.unwrap();
assert_eq!(r2["content"], "abcdef");
let e = exec_edit_file(
&substrate,
&json!({ "path": path, "old_text": "abc", "new_text": "XYZ" }),
)
.await
.unwrap();
assert!(e["edited"].is_string());
let r3 = exec_read_file(&substrate, &json!({ "path": path }))
.await
.unwrap();
assert_eq!(r3["content"], "XYZdef");
std::fs::remove_dir_all(&dir).ok();
}
#[tokio::test]
async fn calculate_is_pure() {
let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
let out = execute(&substrate, "calculate", &json!({ "expression": "2 + 3 * 4" }))
.await
.unwrap()
.unwrap();
assert_eq!(out["result"], 14.0);
}
#[tokio::test]
async fn unknown_tool_returns_none() {
let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
assert!(execute(&substrate, "nope", &json!({})).await.is_none());
}
fn calc(expr: &str) -> f64 {
exec_calculate(&json!({ "expression": expr }))
.unwrap_or_else(|e| panic!("calculate({expr:?}) failed: {e}"))
.get("result")
.and_then(|v| v.as_f64())
.unwrap_or_else(|| panic!("calculate({expr:?}) returned no numeric result"))
}
#[test]
fn calculate_contract_semantics() {
assert_eq!(calc("2 + 3 * 4"), 14.0, "operator precedence");
assert_eq!(calc("(1 + 2) * 3"), 9.0, "parentheses override precedence");
assert_eq!(calc("2^3"), 8.0, "^ is exponentiation, not XOR");
assert_eq!(calc("2^10"), 1024.0, "^ is exponentiation");
assert_eq!(calc("10 % 3"), 1.0, "modulo");
assert_eq!(calc("-5 + 2"), -3.0, "unary minus");
assert_eq!(calc("sin(0)"), 0.0, "native function: sin");
assert_eq!(calc("abs(-3)"), 3.0, "native function: abs");
assert_eq!(calc("sqrt(16)"), 4.0, "shim function: sqrt");
assert!((calc("ln(e)") - 1.0).abs() < 1e-12, "shim: ln + e constant");
assert!((calc("pi") - std::f64::consts::PI).abs() < 1e-12, "shim: pi constant");
}
#[test]
fn calculate_rejects_invalid_expression() {
assert!(exec_calculate(&json!({ "expression": "2 +" })).is_err());
assert!(exec_calculate(&json!({})).is_err(), "missing parameter");
}
}