1use std::path::{Path, PathBuf};
5
6use schemars::JsonSchema;
7use serde::Deserialize;
8
9use crate::executor::{
10 DiffData, ToolCall, ToolError, ToolExecutor, ToolOutput, deserialize_params,
11};
12use crate::registry::{InvocationHint, ToolDef};
13
14#[derive(Deserialize, JsonSchema)]
15pub(crate) struct ReadParams {
16 path: String,
18 offset: Option<u32>,
20 limit: Option<u32>,
22}
23
24#[derive(Deserialize, JsonSchema)]
25struct WriteParams {
26 path: String,
28 content: String,
30}
31
32#[derive(Deserialize, JsonSchema)]
33struct EditParams {
34 path: String,
36 old_string: String,
38 new_string: String,
40}
41
42#[derive(Deserialize, JsonSchema)]
43struct FindPathParams {
44 pattern: String,
46}
47
48#[derive(Deserialize, JsonSchema)]
49struct GrepParams {
50 pattern: String,
52 path: Option<String>,
54 case_sensitive: Option<bool>,
56}
57
58#[derive(Deserialize, JsonSchema)]
59struct ListDirectoryParams {
60 path: String,
62}
63
64#[derive(Deserialize, JsonSchema)]
65struct CreateDirectoryParams {
66 path: String,
68}
69
70#[derive(Deserialize, JsonSchema)]
71struct DeletePathParams {
72 path: String,
74 #[serde(default)]
76 recursive: bool,
77}
78
79#[derive(Deserialize, JsonSchema)]
80struct MovePathParams {
81 source: String,
83 destination: String,
85}
86
87#[derive(Deserialize, JsonSchema)]
88struct CopyPathParams {
89 source: String,
91 destination: String,
93}
94
95#[derive(Debug)]
97pub struct FileExecutor {
98 allowed_paths: Vec<PathBuf>,
99}
100
101impl FileExecutor {
102 #[must_use]
103 pub fn new(allowed_paths: Vec<PathBuf>) -> Self {
104 let paths = if allowed_paths.is_empty() {
105 vec![std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))]
106 } else {
107 allowed_paths
108 };
109 Self {
110 allowed_paths: paths
111 .into_iter()
112 .map(|p| p.canonicalize().unwrap_or(p))
113 .collect(),
114 }
115 }
116
117 fn validate_path(&self, path: &Path) -> Result<PathBuf, ToolError> {
118 let resolved = if path.is_absolute() {
119 path.to_path_buf()
120 } else {
121 std::env::current_dir()
122 .unwrap_or_else(|_| PathBuf::from("."))
123 .join(path)
124 };
125 let canonical = resolve_via_ancestors(&resolved);
126 if !self.allowed_paths.iter().any(|a| canonical.starts_with(a)) {
127 return Err(ToolError::SandboxViolation {
128 path: canonical.display().to_string(),
129 });
130 }
131 Ok(canonical)
132 }
133
134 pub fn execute_file_tool(
140 &self,
141 tool_id: &str,
142 params: &serde_json::Map<String, serde_json::Value>,
143 ) -> Result<Option<ToolOutput>, ToolError> {
144 match tool_id {
145 "read" => {
146 let p: ReadParams = deserialize_params(params)?;
147 self.handle_read(&p)
148 }
149 "write" => {
150 let p: WriteParams = deserialize_params(params)?;
151 self.handle_write(&p)
152 }
153 "edit" => {
154 let p: EditParams = deserialize_params(params)?;
155 self.handle_edit(&p)
156 }
157 "find_path" => {
158 let p: FindPathParams = deserialize_params(params)?;
159 self.handle_find_path(&p)
160 }
161 "grep" => {
162 let p: GrepParams = deserialize_params(params)?;
163 self.handle_grep(&p)
164 }
165 "list_directory" => {
166 let p: ListDirectoryParams = deserialize_params(params)?;
167 self.handle_list_directory(&p)
168 }
169 "create_directory" => {
170 let p: CreateDirectoryParams = deserialize_params(params)?;
171 self.handle_create_directory(&p)
172 }
173 "delete_path" => {
174 let p: DeletePathParams = deserialize_params(params)?;
175 self.handle_delete_path(&p)
176 }
177 "move_path" => {
178 let p: MovePathParams = deserialize_params(params)?;
179 self.handle_move_path(&p)
180 }
181 "copy_path" => {
182 let p: CopyPathParams = deserialize_params(params)?;
183 self.handle_copy_path(&p)
184 }
185 _ => Ok(None),
186 }
187 }
188
189 fn handle_read(&self, params: &ReadParams) -> Result<Option<ToolOutput>, ToolError> {
190 let path = self.validate_path(Path::new(¶ms.path))?;
191 let content = std::fs::read_to_string(&path)?;
192
193 let offset = params.offset.unwrap_or(0) as usize;
194 let limit = params.limit.map_or(usize::MAX, |l| l as usize);
195
196 let selected: Vec<String> = content
197 .lines()
198 .skip(offset)
199 .take(limit)
200 .enumerate()
201 .map(|(i, line)| format!("{:>4}\t{line}", offset + i + 1))
202 .collect();
203
204 Ok(Some(ToolOutput {
205 tool_name: "read".to_owned(),
206 summary: selected.join("\n"),
207 blocks_executed: 1,
208 filter_stats: None,
209 diff: None,
210 streamed: false,
211 terminal_id: None,
212 locations: None,
213 raw_response: None,
214 }))
215 }
216
217 fn handle_write(&self, params: &WriteParams) -> Result<Option<ToolOutput>, ToolError> {
218 let path = self.validate_path(Path::new(¶ms.path))?;
219 let old_content = std::fs::read_to_string(&path).unwrap_or_default();
220
221 if let Some(parent) = path.parent() {
222 std::fs::create_dir_all(parent)?;
223 }
224 std::fs::write(&path, ¶ms.content)?;
225
226 Ok(Some(ToolOutput {
227 tool_name: "write".to_owned(),
228 summary: format!("Wrote {} bytes to {}", params.content.len(), params.path),
229 blocks_executed: 1,
230 filter_stats: None,
231 diff: Some(DiffData {
232 file_path: params.path.clone(),
233 old_content,
234 new_content: params.content.clone(),
235 }),
236 streamed: false,
237 terminal_id: None,
238 locations: None,
239 raw_response: None,
240 }))
241 }
242
243 fn handle_edit(&self, params: &EditParams) -> Result<Option<ToolOutput>, ToolError> {
244 let path = self.validate_path(Path::new(¶ms.path))?;
245 let content = std::fs::read_to_string(&path)?;
246
247 if !content.contains(¶ms.old_string) {
248 return Err(ToolError::Execution(std::io::Error::new(
249 std::io::ErrorKind::NotFound,
250 format!("old_string not found in {}", params.path),
251 )));
252 }
253
254 let new_content = content.replacen(¶ms.old_string, ¶ms.new_string, 1);
255 std::fs::write(&path, &new_content)?;
256
257 Ok(Some(ToolOutput {
258 tool_name: "edit".to_owned(),
259 summary: format!("Edited {}", params.path),
260 blocks_executed: 1,
261 filter_stats: None,
262 diff: Some(DiffData {
263 file_path: params.path.clone(),
264 old_content: content,
265 new_content,
266 }),
267 streamed: false,
268 terminal_id: None,
269 locations: None,
270 raw_response: None,
271 }))
272 }
273
274 fn handle_find_path(&self, params: &FindPathParams) -> Result<Option<ToolOutput>, ToolError> {
275 let matches: Vec<String> = glob::glob(¶ms.pattern)
276 .map_err(|e| {
277 ToolError::Execution(std::io::Error::new(
278 std::io::ErrorKind::InvalidInput,
279 e.to_string(),
280 ))
281 })?
282 .filter_map(Result::ok)
283 .filter(|p| {
284 let canonical = p.canonicalize().unwrap_or_else(|_| p.clone());
285 self.allowed_paths.iter().any(|a| canonical.starts_with(a))
286 })
287 .map(|p| p.display().to_string())
288 .collect();
289
290 Ok(Some(ToolOutput {
291 tool_name: "find_path".to_owned(),
292 summary: if matches.is_empty() {
293 format!("No files matching: {}", params.pattern)
294 } else {
295 matches.join("\n")
296 },
297 blocks_executed: 1,
298 filter_stats: None,
299 diff: None,
300 streamed: false,
301 terminal_id: None,
302 locations: None,
303 raw_response: None,
304 }))
305 }
306
307 fn handle_grep(&self, params: &GrepParams) -> Result<Option<ToolOutput>, ToolError> {
308 let search_path = params.path.as_deref().unwrap_or(".");
309 let case_sensitive = params.case_sensitive.unwrap_or(true);
310 let path = self.validate_path(Path::new(search_path))?;
311
312 let regex = if case_sensitive {
313 regex::Regex::new(¶ms.pattern)
314 } else {
315 regex::RegexBuilder::new(¶ms.pattern)
316 .case_insensitive(true)
317 .build()
318 }
319 .map_err(|e| {
320 ToolError::Execution(std::io::Error::new(
321 std::io::ErrorKind::InvalidInput,
322 e.to_string(),
323 ))
324 })?;
325
326 let mut results = Vec::new();
327 grep_recursive(&path, ®ex, &mut results, 100)?;
328
329 Ok(Some(ToolOutput {
330 tool_name: "grep".to_owned(),
331 summary: if results.is_empty() {
332 format!("No matches for: {}", params.pattern)
333 } else {
334 results.join("\n")
335 },
336 blocks_executed: 1,
337 filter_stats: None,
338 diff: None,
339 streamed: false,
340 terminal_id: None,
341 locations: None,
342 raw_response: None,
343 }))
344 }
345
346 fn handle_list_directory(
347 &self,
348 params: &ListDirectoryParams,
349 ) -> Result<Option<ToolOutput>, ToolError> {
350 let path = self.validate_path(Path::new(¶ms.path))?;
351
352 if !path.is_dir() {
353 return Err(ToolError::Execution(std::io::Error::new(
354 std::io::ErrorKind::NotADirectory,
355 format!("{} is not a directory", params.path),
356 )));
357 }
358
359 let mut dirs = Vec::new();
360 let mut files = Vec::new();
361 let mut symlinks = Vec::new();
362
363 for entry in std::fs::read_dir(&path)? {
364 let entry = entry?;
365 let name = entry.file_name().to_string_lossy().into_owned();
366 let meta = std::fs::symlink_metadata(entry.path())?;
368 if meta.is_symlink() {
369 symlinks.push(format!("[symlink] {name}"));
370 } else if meta.is_dir() {
371 dirs.push(format!("[dir] {name}"));
372 } else {
373 files.push(format!("[file] {name}"));
374 }
375 }
376
377 dirs.sort();
378 files.sort();
379 symlinks.sort();
380
381 let mut entries = dirs;
382 entries.extend(files);
383 entries.extend(symlinks);
384
385 Ok(Some(ToolOutput {
386 tool_name: "list_directory".to_owned(),
387 summary: if entries.is_empty() {
388 format!("Empty directory: {}", params.path)
389 } else {
390 entries.join("\n")
391 },
392 blocks_executed: 1,
393 filter_stats: None,
394 diff: None,
395 streamed: false,
396 terminal_id: None,
397 locations: None,
398 raw_response: None,
399 }))
400 }
401
402 fn handle_create_directory(
403 &self,
404 params: &CreateDirectoryParams,
405 ) -> Result<Option<ToolOutput>, ToolError> {
406 let path = self.validate_path(Path::new(¶ms.path))?;
407 std::fs::create_dir_all(&path)?;
408
409 Ok(Some(ToolOutput {
410 tool_name: "create_directory".to_owned(),
411 summary: format!("Created directory: {}", params.path),
412 blocks_executed: 1,
413 filter_stats: None,
414 diff: None,
415 streamed: false,
416 terminal_id: None,
417 locations: None,
418 raw_response: None,
419 }))
420 }
421
422 fn handle_delete_path(
423 &self,
424 params: &DeletePathParams,
425 ) -> Result<Option<ToolOutput>, ToolError> {
426 let path = self.validate_path(Path::new(¶ms.path))?;
427
428 if self.allowed_paths.iter().any(|a| &path == a) {
430 return Err(ToolError::SandboxViolation {
431 path: path.display().to_string(),
432 });
433 }
434
435 if path.is_dir() {
436 if params.recursive {
437 std::fs::remove_dir_all(&path)?;
440 } else {
441 std::fs::remove_dir(&path)?;
443 }
444 } else {
445 std::fs::remove_file(&path)?;
446 }
447
448 Ok(Some(ToolOutput {
449 tool_name: "delete_path".to_owned(),
450 summary: format!("Deleted: {}", params.path),
451 blocks_executed: 1,
452 filter_stats: None,
453 diff: None,
454 streamed: false,
455 terminal_id: None,
456 locations: None,
457 raw_response: None,
458 }))
459 }
460
461 fn handle_move_path(&self, params: &MovePathParams) -> Result<Option<ToolOutput>, ToolError> {
462 let src = self.validate_path(Path::new(¶ms.source))?;
463 let dst = self.validate_path(Path::new(¶ms.destination))?;
464 std::fs::rename(&src, &dst)?;
465
466 Ok(Some(ToolOutput {
467 tool_name: "move_path".to_owned(),
468 summary: format!("Moved: {} -> {}", params.source, params.destination),
469 blocks_executed: 1,
470 filter_stats: None,
471 diff: None,
472 streamed: false,
473 terminal_id: None,
474 locations: None,
475 raw_response: None,
476 }))
477 }
478
479 fn handle_copy_path(&self, params: &CopyPathParams) -> Result<Option<ToolOutput>, ToolError> {
480 let src = self.validate_path(Path::new(¶ms.source))?;
481 let dst = self.validate_path(Path::new(¶ms.destination))?;
482
483 if src.is_dir() {
484 copy_dir_recursive(&src, &dst)?;
485 } else {
486 if let Some(parent) = dst.parent() {
487 std::fs::create_dir_all(parent)?;
488 }
489 std::fs::copy(&src, &dst)?;
490 }
491
492 Ok(Some(ToolOutput {
493 tool_name: "copy_path".to_owned(),
494 summary: format!("Copied: {} -> {}", params.source, params.destination),
495 blocks_executed: 1,
496 filter_stats: None,
497 diff: None,
498 streamed: false,
499 terminal_id: None,
500 locations: None,
501 raw_response: None,
502 }))
503 }
504}
505
506impl ToolExecutor for FileExecutor {
507 async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
508 Ok(None)
509 }
510
511 async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
512 self.execute_file_tool(&call.tool_id, &call.params)
513 }
514
515 fn tool_definitions(&self) -> Vec<ToolDef> {
516 vec![
517 ToolDef {
518 id: "read".into(),
519 description: "Read file contents with line numbers.\n\nParameters: path (string, required) - absolute or relative file path; offset (integer, optional) - start line (0-based); limit (integer, optional) - max lines to return\nReturns: file content with line numbers, or error if file not found\nErrors: SandboxViolation if path outside allowed dirs; Execution if file not found or unreadable\nExample: {\"path\": \"src/main.rs\", \"offset\": 10, \"limit\": 50}".into(),
520 schema: schemars::schema_for!(ReadParams),
521 invocation: InvocationHint::ToolCall,
522 },
523 ToolDef {
524 id: "write".into(),
525 description: "Create or overwrite a file with the given content.\n\nParameters: path (string, required) - file path; content (string, required) - full file content\nReturns: confirmation message with bytes written\nErrors: SandboxViolation if path outside allowed dirs; Execution on I/O failure\nExample: {\"path\": \"output.txt\", \"content\": \"Hello, world!\"}".into(),
526 schema: schemars::schema_for!(WriteParams),
527 invocation: InvocationHint::ToolCall,
528 },
529 ToolDef {
530 id: "edit".into(),
531 description: "Find and replace a text substring in a file.\n\nParameters: path (string, required) - file path; old_string (string, required) - exact text to find; new_string (string, required) - replacement text\nReturns: confirmation with match count, or error if old_string not found\nErrors: SandboxViolation; Execution if file not found or old_string has no matches\nExample: {\"path\": \"config.toml\", \"old_string\": \"debug = true\", \"new_string\": \"debug = false\"}".into(),
532 schema: schemars::schema_for!(EditParams),
533 invocation: InvocationHint::ToolCall,
534 },
535 ToolDef {
536 id: "find_path".into(),
537 description: "Find files and directories matching a glob pattern.\n\nParameters: pattern (string, required) - glob pattern (e.g. \"**/*.rs\", \"src/*.toml\")\nReturns: newline-separated list of matching paths, or \"(no matches)\" if none found\nErrors: SandboxViolation if search root is outside allowed dirs\nExample: {\"pattern\": \"**/*.rs\"}".into(),
538 schema: schemars::schema_for!(FindPathParams),
539 invocation: InvocationHint::ToolCall,
540 },
541 ToolDef {
542 id: "grep".into(),
543 description: "Search file contents for lines matching a regex pattern.\n\nParameters: pattern (string, required) - regex pattern; path (string, optional) - directory or file to search (default: cwd); case_sensitive (boolean, optional) - default true\nReturns: matching lines with file paths and line numbers, or \"(no matches)\"\nErrors: SandboxViolation; InvalidParams if regex is invalid\nExample: {\"pattern\": \"fn main\", \"path\": \"src/\"}".into(),
544 schema: schemars::schema_for!(GrepParams),
545 invocation: InvocationHint::ToolCall,
546 },
547 ToolDef {
548 id: "list_directory".into(),
549 description: "List files and subdirectories in a directory.\n\nParameters: path (string, required) - directory path\nReturns: sorted listing with [dir]/[file] prefixes, or \"Empty directory\" if empty\nErrors: SandboxViolation; Execution if path is not a directory or does not exist\nExample: {\"path\": \"src/\"}".into(),
550 schema: schemars::schema_for!(ListDirectoryParams),
551 invocation: InvocationHint::ToolCall,
552 },
553 ToolDef {
554 id: "create_directory".into(),
555 description: "Create a directory, including any missing parent directories.\n\nParameters: path (string, required) - directory path to create\nReturns: confirmation message\nErrors: SandboxViolation; Execution on I/O failure\nExample: {\"path\": \"src/utils/helpers\"}".into(),
556 schema: schemars::schema_for!(CreateDirectoryParams),
557 invocation: InvocationHint::ToolCall,
558 },
559 ToolDef {
560 id: "delete_path".into(),
561 description: "Delete a file or directory.\n\nParameters: path (string, required) - path to delete; recursive (boolean, optional) - if true, delete non-empty directories recursively (default: false)\nReturns: confirmation message\nErrors: SandboxViolation; Execution if path not found or directory non-empty without recursive=true\nExample: {\"path\": \"tmp/old_file.txt\"}".into(),
562 schema: schemars::schema_for!(DeletePathParams),
563 invocation: InvocationHint::ToolCall,
564 },
565 ToolDef {
566 id: "move_path".into(),
567 description: "Move or rename a file or directory.\n\nParameters: source (string, required) - current path; destination (string, required) - new path\nReturns: confirmation message\nErrors: SandboxViolation if either path is outside allowed dirs; Execution if source not found\nExample: {\"source\": \"old_name.rs\", \"destination\": \"new_name.rs\"}".into(),
568 schema: schemars::schema_for!(MovePathParams),
569 invocation: InvocationHint::ToolCall,
570 },
571 ToolDef {
572 id: "copy_path".into(),
573 description: "Copy a file or directory to a new location.\n\nParameters: source (string, required) - path to copy; destination (string, required) - target path\nReturns: confirmation message\nErrors: SandboxViolation; Execution if source not found or I/O failure\nExample: {\"source\": \"template.rs\", \"destination\": \"new_module.rs\"}".into(),
574 schema: schemars::schema_for!(CopyPathParams),
575 invocation: InvocationHint::ToolCall,
576 },
577 ]
578 }
579}
580
581fn resolve_via_ancestors(path: &Path) -> PathBuf {
588 let mut existing = path;
589 let mut suffix = PathBuf::new();
590 while !existing.exists() {
591 if let Some(parent) = existing.parent() {
592 if let Some(name) = existing.file_name() {
593 if suffix.as_os_str().is_empty() {
594 suffix = PathBuf::from(name);
595 } else {
596 suffix = PathBuf::from(name).join(&suffix);
597 }
598 }
599 existing = parent;
600 } else {
601 break;
602 }
603 }
604 let base = existing.canonicalize().unwrap_or(existing.to_path_buf());
605 if suffix.as_os_str().is_empty() {
606 base
607 } else {
608 base.join(&suffix)
609 }
610}
611
612const IGNORED_DIRS: &[&str] = &[".git", "target", "node_modules", ".hg"];
613
614fn grep_recursive(
615 path: &Path,
616 regex: ®ex::Regex,
617 results: &mut Vec<String>,
618 limit: usize,
619) -> Result<(), ToolError> {
620 if results.len() >= limit {
621 return Ok(());
622 }
623 if path.is_file() {
624 if let Ok(content) = std::fs::read_to_string(path) {
625 for (i, line) in content.lines().enumerate() {
626 if regex.is_match(line) {
627 results.push(format!("{}:{}: {line}", path.display(), i + 1));
628 if results.len() >= limit {
629 return Ok(());
630 }
631 }
632 }
633 }
634 } else if path.is_dir() {
635 let entries = std::fs::read_dir(path)?;
636 for entry in entries.flatten() {
637 let p = entry.path();
638 let name = p.file_name().and_then(|n| n.to_str());
639 if name.is_some_and(|n| n.starts_with('.') || IGNORED_DIRS.contains(&n)) {
640 continue;
641 }
642 grep_recursive(&p, regex, results, limit)?;
643 }
644 }
645 Ok(())
646}
647
648fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), ToolError> {
649 std::fs::create_dir_all(dst)?;
650 for entry in std::fs::read_dir(src)? {
651 let entry = entry?;
652 let meta = std::fs::symlink_metadata(entry.path())?;
656 let src_path = entry.path();
657 let dst_path = dst.join(entry.file_name());
658 if meta.is_dir() {
659 copy_dir_recursive(&src_path, &dst_path)?;
660 } else if meta.is_file() {
661 std::fs::copy(&src_path, &dst_path)?;
662 }
663 }
665 Ok(())
666}
667
668#[cfg(test)]
669mod tests {
670 use super::*;
671 use std::fs;
672
673 fn temp_dir() -> tempfile::TempDir {
674 tempfile::tempdir().unwrap()
675 }
676
677 fn make_params(
678 pairs: &[(&str, serde_json::Value)],
679 ) -> serde_json::Map<String, serde_json::Value> {
680 pairs
681 .iter()
682 .map(|(k, v)| ((*k).to_owned(), v.clone()))
683 .collect()
684 }
685
686 #[test]
687 fn read_file() {
688 let dir = temp_dir();
689 let file = dir.path().join("test.txt");
690 fs::write(&file, "line1\nline2\nline3\n").unwrap();
691
692 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
693 let params = make_params(&[("path", serde_json::json!(file.to_str().unwrap()))]);
694 let result = exec.execute_file_tool("read", ¶ms).unwrap().unwrap();
695 assert_eq!(result.tool_name, "read");
696 assert!(result.summary.contains("line1"));
697 assert!(result.summary.contains("line3"));
698 }
699
700 #[test]
701 fn read_with_offset_and_limit() {
702 let dir = temp_dir();
703 let file = dir.path().join("test.txt");
704 fs::write(&file, "a\nb\nc\nd\ne\n").unwrap();
705
706 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
707 let params = make_params(&[
708 ("path", serde_json::json!(file.to_str().unwrap())),
709 ("offset", serde_json::json!(1)),
710 ("limit", serde_json::json!(2)),
711 ]);
712 let result = exec.execute_file_tool("read", ¶ms).unwrap().unwrap();
713 assert!(result.summary.contains("b"));
714 assert!(result.summary.contains("c"));
715 assert!(!result.summary.contains("a"));
716 assert!(!result.summary.contains("d"));
717 }
718
719 #[test]
720 fn write_file() {
721 let dir = temp_dir();
722 let file = dir.path().join("out.txt");
723
724 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
725 let params = make_params(&[
726 ("path", serde_json::json!(file.to_str().unwrap())),
727 ("content", serde_json::json!("hello world")),
728 ]);
729 let result = exec.execute_file_tool("write", ¶ms).unwrap().unwrap();
730 assert!(result.summary.contains("11 bytes"));
731 assert_eq!(fs::read_to_string(&file).unwrap(), "hello world");
732 }
733
734 #[test]
735 fn edit_file() {
736 let dir = temp_dir();
737 let file = dir.path().join("edit.txt");
738 fs::write(&file, "foo bar baz").unwrap();
739
740 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
741 let params = make_params(&[
742 ("path", serde_json::json!(file.to_str().unwrap())),
743 ("old_string", serde_json::json!("bar")),
744 ("new_string", serde_json::json!("qux")),
745 ]);
746 let result = exec.execute_file_tool("edit", ¶ms).unwrap().unwrap();
747 assert!(result.summary.contains("Edited"));
748 assert_eq!(fs::read_to_string(&file).unwrap(), "foo qux baz");
749 }
750
751 #[test]
752 fn edit_not_found() {
753 let dir = temp_dir();
754 let file = dir.path().join("edit.txt");
755 fs::write(&file, "foo bar").unwrap();
756
757 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
758 let params = make_params(&[
759 ("path", serde_json::json!(file.to_str().unwrap())),
760 ("old_string", serde_json::json!("nonexistent")),
761 ("new_string", serde_json::json!("x")),
762 ]);
763 let result = exec.execute_file_tool("edit", ¶ms);
764 assert!(result.is_err());
765 }
766
767 #[test]
768 fn sandbox_violation() {
769 let dir = temp_dir();
770 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
771 let params = make_params(&[("path", serde_json::json!("/etc/passwd"))]);
772 let result = exec.execute_file_tool("read", ¶ms);
773 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
774 }
775
776 #[test]
777 fn unknown_tool_returns_none() {
778 let exec = FileExecutor::new(vec![]);
779 let params = serde_json::Map::new();
780 let result = exec.execute_file_tool("unknown", ¶ms).unwrap();
781 assert!(result.is_none());
782 }
783
784 #[test]
785 fn find_path_finds_files() {
786 let dir = temp_dir();
787 fs::write(dir.path().join("a.rs"), "").unwrap();
788 fs::write(dir.path().join("b.rs"), "").unwrap();
789
790 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
791 let pattern = format!("{}/*.rs", dir.path().display());
792 let params = make_params(&[("pattern", serde_json::json!(pattern))]);
793 let result = exec
794 .execute_file_tool("find_path", ¶ms)
795 .unwrap()
796 .unwrap();
797 assert!(result.summary.contains("a.rs"));
798 assert!(result.summary.contains("b.rs"));
799 }
800
801 #[test]
802 fn grep_finds_matches() {
803 let dir = temp_dir();
804 fs::write(
805 dir.path().join("test.txt"),
806 "hello world\nfoo bar\nhello again\n",
807 )
808 .unwrap();
809
810 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
811 let params = make_params(&[
812 ("pattern", serde_json::json!("hello")),
813 ("path", serde_json::json!(dir.path().to_str().unwrap())),
814 ]);
815 let result = exec.execute_file_tool("grep", ¶ms).unwrap().unwrap();
816 assert!(result.summary.contains("hello world"));
817 assert!(result.summary.contains("hello again"));
818 assert!(!result.summary.contains("foo bar"));
819 }
820
821 #[test]
822 fn write_sandbox_bypass_nonexistent_path() {
823 let dir = temp_dir();
824 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
825 let params = make_params(&[
826 ("path", serde_json::json!("/tmp/evil/escape.txt")),
827 ("content", serde_json::json!("pwned")),
828 ]);
829 let result = exec.execute_file_tool("write", ¶ms);
830 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
831 assert!(!Path::new("/tmp/evil/escape.txt").exists());
832 }
833
834 #[test]
835 fn find_path_filters_outside_sandbox() {
836 let sandbox = temp_dir();
837 let outside = temp_dir();
838 fs::write(outside.path().join("secret.rs"), "secret").unwrap();
839
840 let exec = FileExecutor::new(vec![sandbox.path().to_path_buf()]);
841 let pattern = format!("{}/*.rs", outside.path().display());
842 let params = make_params(&[("pattern", serde_json::json!(pattern))]);
843 let result = exec
844 .execute_file_tool("find_path", ¶ms)
845 .unwrap()
846 .unwrap();
847 assert!(!result.summary.contains("secret.rs"));
848 }
849
850 #[tokio::test]
851 async fn tool_executor_execute_tool_call_delegates() {
852 let dir = temp_dir();
853 let file = dir.path().join("test.txt");
854 fs::write(&file, "content").unwrap();
855
856 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
857 let call = ToolCall {
858 tool_id: "read".to_owned(),
859 params: make_params(&[("path", serde_json::json!(file.to_str().unwrap()))]),
860 };
861 let result = exec.execute_tool_call(&call).await.unwrap().unwrap();
862 assert_eq!(result.tool_name, "read");
863 assert!(result.summary.contains("content"));
864 }
865
866 #[test]
867 fn tool_executor_tool_definitions_lists_all() {
868 let exec = FileExecutor::new(vec![]);
869 let defs = exec.tool_definitions();
870 let ids: Vec<&str> = defs.iter().map(|d| d.id.as_ref()).collect();
871 assert!(ids.contains(&"read"));
872 assert!(ids.contains(&"write"));
873 assert!(ids.contains(&"edit"));
874 assert!(ids.contains(&"find_path"));
875 assert!(ids.contains(&"grep"));
876 assert!(ids.contains(&"list_directory"));
877 assert!(ids.contains(&"create_directory"));
878 assert!(ids.contains(&"delete_path"));
879 assert!(ids.contains(&"move_path"));
880 assert!(ids.contains(&"copy_path"));
881 assert_eq!(defs.len(), 10);
882 }
883
884 #[test]
885 fn grep_relative_path_validated() {
886 let sandbox = temp_dir();
887 let exec = FileExecutor::new(vec![sandbox.path().to_path_buf()]);
888 let params = make_params(&[
889 ("pattern", serde_json::json!("password")),
890 ("path", serde_json::json!("../../etc")),
891 ]);
892 let result = exec.execute_file_tool("grep", ¶ms);
893 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
894 }
895
896 #[test]
897 fn tool_definitions_returns_ten_tools() {
898 let exec = FileExecutor::new(vec![]);
899 let defs = exec.tool_definitions();
900 assert_eq!(defs.len(), 10);
901 let ids: Vec<&str> = defs.iter().map(|d| d.id.as_ref()).collect();
902 assert_eq!(
903 ids,
904 vec![
905 "read",
906 "write",
907 "edit",
908 "find_path",
909 "grep",
910 "list_directory",
911 "create_directory",
912 "delete_path",
913 "move_path",
914 "copy_path",
915 ]
916 );
917 }
918
919 #[test]
920 fn tool_definitions_all_use_tool_call() {
921 let exec = FileExecutor::new(vec![]);
922 for def in exec.tool_definitions() {
923 assert_eq!(def.invocation, InvocationHint::ToolCall);
924 }
925 }
926
927 #[test]
928 fn tool_definitions_read_schema_has_params() {
929 let exec = FileExecutor::new(vec![]);
930 let defs = exec.tool_definitions();
931 let read = defs.iter().find(|d| d.id.as_ref() == "read").unwrap();
932 let obj = read.schema.as_object().unwrap();
933 let props = obj["properties"].as_object().unwrap();
934 assert!(props.contains_key("path"));
935 assert!(props.contains_key("offset"));
936 assert!(props.contains_key("limit"));
937 }
938
939 #[test]
940 fn missing_required_path_returns_invalid_params() {
941 let dir = temp_dir();
942 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
943 let params = serde_json::Map::new();
944 let result = exec.execute_file_tool("read", ¶ms);
945 assert!(matches!(result, Err(ToolError::InvalidParams { .. })));
946 }
947
948 #[test]
951 fn list_directory_returns_entries() {
952 let dir = temp_dir();
953 fs::write(dir.path().join("file.txt"), "").unwrap();
954 fs::create_dir(dir.path().join("subdir")).unwrap();
955
956 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
957 let params = make_params(&[("path", serde_json::json!(dir.path().to_str().unwrap()))]);
958 let result = exec
959 .execute_file_tool("list_directory", ¶ms)
960 .unwrap()
961 .unwrap();
962 assert!(result.summary.contains("[dir] subdir"));
963 assert!(result.summary.contains("[file] file.txt"));
964 let dir_pos = result.summary.find("[dir]").unwrap();
966 let file_pos = result.summary.find("[file]").unwrap();
967 assert!(dir_pos < file_pos);
968 }
969
970 #[test]
971 fn list_directory_empty_dir() {
972 let dir = temp_dir();
973 let subdir = dir.path().join("empty");
974 fs::create_dir(&subdir).unwrap();
975
976 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
977 let params = make_params(&[("path", serde_json::json!(subdir.to_str().unwrap()))]);
978 let result = exec
979 .execute_file_tool("list_directory", ¶ms)
980 .unwrap()
981 .unwrap();
982 assert!(result.summary.contains("Empty directory"));
983 }
984
985 #[test]
986 fn list_directory_sandbox_violation() {
987 let dir = temp_dir();
988 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
989 let params = make_params(&[("path", serde_json::json!("/etc"))]);
990 let result = exec.execute_file_tool("list_directory", ¶ms);
991 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
992 }
993
994 #[test]
995 fn list_directory_nonexistent_returns_error() {
996 let dir = temp_dir();
997 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
998 let missing = dir.path().join("nonexistent");
999 let params = make_params(&[("path", serde_json::json!(missing.to_str().unwrap()))]);
1000 let result = exec.execute_file_tool("list_directory", ¶ms);
1001 assert!(result.is_err());
1002 }
1003
1004 #[test]
1005 fn list_directory_on_file_returns_error() {
1006 let dir = temp_dir();
1007 let file = dir.path().join("file.txt");
1008 fs::write(&file, "content").unwrap();
1009
1010 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1011 let params = make_params(&[("path", serde_json::json!(file.to_str().unwrap()))]);
1012 let result = exec.execute_file_tool("list_directory", ¶ms);
1013 assert!(result.is_err());
1014 }
1015
1016 #[test]
1019 fn create_directory_creates_nested() {
1020 let dir = temp_dir();
1021 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1022 let nested = dir.path().join("a/b/c");
1023 let params = make_params(&[("path", serde_json::json!(nested.to_str().unwrap()))]);
1024 let result = exec
1025 .execute_file_tool("create_directory", ¶ms)
1026 .unwrap()
1027 .unwrap();
1028 assert!(result.summary.contains("Created"));
1029 assert!(nested.is_dir());
1030 }
1031
1032 #[test]
1033 fn create_directory_sandbox_violation() {
1034 let dir = temp_dir();
1035 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1036 let params = make_params(&[("path", serde_json::json!("/tmp/evil_dir"))]);
1037 let result = exec.execute_file_tool("create_directory", ¶ms);
1038 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1039 }
1040
1041 #[test]
1044 fn delete_path_file() {
1045 let dir = temp_dir();
1046 let file = dir.path().join("del.txt");
1047 fs::write(&file, "bye").unwrap();
1048
1049 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1050 let params = make_params(&[("path", serde_json::json!(file.to_str().unwrap()))]);
1051 exec.execute_file_tool("delete_path", ¶ms)
1052 .unwrap()
1053 .unwrap();
1054 assert!(!file.exists());
1055 }
1056
1057 #[test]
1058 fn delete_path_empty_directory() {
1059 let dir = temp_dir();
1060 let subdir = dir.path().join("empty_sub");
1061 fs::create_dir(&subdir).unwrap();
1062
1063 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1064 let params = make_params(&[("path", serde_json::json!(subdir.to_str().unwrap()))]);
1065 exec.execute_file_tool("delete_path", ¶ms)
1066 .unwrap()
1067 .unwrap();
1068 assert!(!subdir.exists());
1069 }
1070
1071 #[test]
1072 fn delete_path_non_empty_dir_without_recursive_fails() {
1073 let dir = temp_dir();
1074 let subdir = dir.path().join("nonempty");
1075 fs::create_dir(&subdir).unwrap();
1076 fs::write(subdir.join("file.txt"), "x").unwrap();
1077
1078 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1079 let params = make_params(&[("path", serde_json::json!(subdir.to_str().unwrap()))]);
1080 let result = exec.execute_file_tool("delete_path", ¶ms);
1081 assert!(result.is_err());
1082 }
1083
1084 #[test]
1085 fn delete_path_recursive() {
1086 let dir = temp_dir();
1087 let subdir = dir.path().join("recurse");
1088 fs::create_dir(&subdir).unwrap();
1089 fs::write(subdir.join("f.txt"), "x").unwrap();
1090
1091 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1092 let params = make_params(&[
1093 ("path", serde_json::json!(subdir.to_str().unwrap())),
1094 ("recursive", serde_json::json!(true)),
1095 ]);
1096 exec.execute_file_tool("delete_path", ¶ms)
1097 .unwrap()
1098 .unwrap();
1099 assert!(!subdir.exists());
1100 }
1101
1102 #[test]
1103 fn delete_path_sandbox_violation() {
1104 let dir = temp_dir();
1105 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1106 let params = make_params(&[("path", serde_json::json!("/etc/hosts"))]);
1107 let result = exec.execute_file_tool("delete_path", ¶ms);
1108 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1109 }
1110
1111 #[test]
1112 fn delete_path_refuses_sandbox_root() {
1113 let dir = temp_dir();
1114 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1115 let params = make_params(&[
1116 ("path", serde_json::json!(dir.path().to_str().unwrap())),
1117 ("recursive", serde_json::json!(true)),
1118 ]);
1119 let result = exec.execute_file_tool("delete_path", ¶ms);
1120 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1121 }
1122
1123 #[test]
1126 fn move_path_renames_file() {
1127 let dir = temp_dir();
1128 let src = dir.path().join("src.txt");
1129 let dst = dir.path().join("dst.txt");
1130 fs::write(&src, "data").unwrap();
1131
1132 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1133 let params = make_params(&[
1134 ("source", serde_json::json!(src.to_str().unwrap())),
1135 ("destination", serde_json::json!(dst.to_str().unwrap())),
1136 ]);
1137 exec.execute_file_tool("move_path", ¶ms)
1138 .unwrap()
1139 .unwrap();
1140 assert!(!src.exists());
1141 assert_eq!(fs::read_to_string(&dst).unwrap(), "data");
1142 }
1143
1144 #[test]
1145 fn move_path_cross_sandbox_denied() {
1146 let sandbox = temp_dir();
1147 let outside = temp_dir();
1148 let src = sandbox.path().join("src.txt");
1149 fs::write(&src, "x").unwrap();
1150
1151 let exec = FileExecutor::new(vec![sandbox.path().to_path_buf()]);
1152 let dst = outside.path().join("dst.txt");
1153 let params = make_params(&[
1154 ("source", serde_json::json!(src.to_str().unwrap())),
1155 ("destination", serde_json::json!(dst.to_str().unwrap())),
1156 ]);
1157 let result = exec.execute_file_tool("move_path", ¶ms);
1158 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1159 }
1160
1161 #[test]
1164 fn copy_path_file() {
1165 let dir = temp_dir();
1166 let src = dir.path().join("src.txt");
1167 let dst = dir.path().join("dst.txt");
1168 fs::write(&src, "hello").unwrap();
1169
1170 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1171 let params = make_params(&[
1172 ("source", serde_json::json!(src.to_str().unwrap())),
1173 ("destination", serde_json::json!(dst.to_str().unwrap())),
1174 ]);
1175 exec.execute_file_tool("copy_path", ¶ms)
1176 .unwrap()
1177 .unwrap();
1178 assert_eq!(fs::read_to_string(&src).unwrap(), "hello");
1179 assert_eq!(fs::read_to_string(&dst).unwrap(), "hello");
1180 }
1181
1182 #[test]
1183 fn copy_path_directory_recursive() {
1184 let dir = temp_dir();
1185 let src_dir = dir.path().join("src_dir");
1186 fs::create_dir(&src_dir).unwrap();
1187 fs::write(src_dir.join("a.txt"), "aaa").unwrap();
1188
1189 let dst_dir = dir.path().join("dst_dir");
1190
1191 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1192 let params = make_params(&[
1193 ("source", serde_json::json!(src_dir.to_str().unwrap())),
1194 ("destination", serde_json::json!(dst_dir.to_str().unwrap())),
1195 ]);
1196 exec.execute_file_tool("copy_path", ¶ms)
1197 .unwrap()
1198 .unwrap();
1199 assert_eq!(fs::read_to_string(dst_dir.join("a.txt")).unwrap(), "aaa");
1200 }
1201
1202 #[test]
1203 fn copy_path_sandbox_violation() {
1204 let sandbox = temp_dir();
1205 let outside = temp_dir();
1206 let src = sandbox.path().join("src.txt");
1207 fs::write(&src, "x").unwrap();
1208
1209 let exec = FileExecutor::new(vec![sandbox.path().to_path_buf()]);
1210 let dst = outside.path().join("dst.txt");
1211 let params = make_params(&[
1212 ("source", serde_json::json!(src.to_str().unwrap())),
1213 ("destination", serde_json::json!(dst.to_str().unwrap())),
1214 ]);
1215 let result = exec.execute_file_tool("copy_path", ¶ms);
1216 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1217 }
1218
1219 #[test]
1221 fn find_path_invalid_pattern_returns_error() {
1222 let dir = temp_dir();
1223 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1224 let params = make_params(&[("pattern", serde_json::json!("[invalid"))]);
1225 let result = exec.execute_file_tool("find_path", ¶ms);
1226 assert!(result.is_err());
1227 }
1228
1229 #[test]
1231 fn create_directory_idempotent() {
1232 let dir = temp_dir();
1233 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1234 let target = dir.path().join("exists");
1235 fs::create_dir(&target).unwrap();
1236
1237 let params = make_params(&[("path", serde_json::json!(target.to_str().unwrap()))]);
1238 let result = exec.execute_file_tool("create_directory", ¶ms);
1239 assert!(result.is_ok());
1240 assert!(target.is_dir());
1241 }
1242
1243 #[test]
1245 fn move_path_source_sandbox_violation() {
1246 let sandbox = temp_dir();
1247 let outside = temp_dir();
1248 let src = outside.path().join("src.txt");
1249 fs::write(&src, "x").unwrap();
1250
1251 let exec = FileExecutor::new(vec![sandbox.path().to_path_buf()]);
1252 let dst = sandbox.path().join("dst.txt");
1253 let params = make_params(&[
1254 ("source", serde_json::json!(src.to_str().unwrap())),
1255 ("destination", serde_json::json!(dst.to_str().unwrap())),
1256 ]);
1257 let result = exec.execute_file_tool("move_path", ¶ms);
1258 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1259 }
1260
1261 #[test]
1263 fn copy_path_source_sandbox_violation() {
1264 let sandbox = temp_dir();
1265 let outside = temp_dir();
1266 let src = outside.path().join("src.txt");
1267 fs::write(&src, "x").unwrap();
1268
1269 let exec = FileExecutor::new(vec![sandbox.path().to_path_buf()]);
1270 let dst = sandbox.path().join("dst.txt");
1271 let params = make_params(&[
1272 ("source", serde_json::json!(src.to_str().unwrap())),
1273 ("destination", serde_json::json!(dst.to_str().unwrap())),
1274 ]);
1275 let result = exec.execute_file_tool("copy_path", ¶ms);
1276 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1277 }
1278
1279 #[cfg(unix)]
1281 #[test]
1282 fn copy_dir_skips_symlinks() {
1283 let dir = temp_dir();
1284 let src_dir = dir.path().join("src");
1285 fs::create_dir(&src_dir).unwrap();
1286 fs::write(src_dir.join("real.txt"), "real").unwrap();
1287
1288 let outside = temp_dir();
1290 std::os::unix::fs::symlink(outside.path(), src_dir.join("link")).unwrap();
1291
1292 let dst_dir = dir.path().join("dst");
1293 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1294 let params = make_params(&[
1295 ("source", serde_json::json!(src_dir.to_str().unwrap())),
1296 ("destination", serde_json::json!(dst_dir.to_str().unwrap())),
1297 ]);
1298 exec.execute_file_tool("copy_path", ¶ms)
1299 .unwrap()
1300 .unwrap();
1301 assert_eq!(
1303 fs::read_to_string(dst_dir.join("real.txt")).unwrap(),
1304 "real"
1305 );
1306 assert!(!dst_dir.join("link").exists());
1308 }
1309
1310 #[cfg(unix)]
1312 #[test]
1313 fn list_directory_shows_symlinks() {
1314 let dir = temp_dir();
1315 let target = dir.path().join("target.txt");
1316 fs::write(&target, "x").unwrap();
1317 std::os::unix::fs::symlink(&target, dir.path().join("link")).unwrap();
1318
1319 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1320 let params = make_params(&[("path", serde_json::json!(dir.path().to_str().unwrap()))]);
1321 let result = exec
1322 .execute_file_tool("list_directory", ¶ms)
1323 .unwrap()
1324 .unwrap();
1325 assert!(result.summary.contains("[symlink] link"));
1326 assert!(result.summary.contains("[file] target.txt"));
1327 }
1328}