1use regex::Regex;
2use serde_json::{json, Value};
3use std::collections::HashMap;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use crate::error::{MojenticError, Result};
8use crate::llm::tools::{FunctionDescriptor, LlmTool, ToolDescriptor};
9
10#[derive(Debug, Clone)]
15pub struct FilesystemGateway {
16 base_path: PathBuf,
17}
18
19impl FilesystemGateway {
20 pub fn new<P: AsRef<Path>>(base_path: P) -> Result<Self> {
22 let base_path = base_path.as_ref();
23
24 if !base_path.is_dir() {
25 return Err(MojenticError::ToolError(format!(
26 "Base path {:?} is not a directory",
27 base_path
28 )));
29 }
30
31 Ok(Self {
32 base_path: base_path.canonicalize()?,
33 })
34 }
35
36 pub fn resolve_path<P: AsRef<Path>>(&self, path: P) -> Result<PathBuf> {
38 let path = path.as_ref();
39
40 let resolved = match self.base_path.join(path).canonicalize() {
42 Ok(canonical) => canonical,
43 Err(_) => {
44 let joined = self.base_path.join(path);
47
48 let mut normalized = PathBuf::new();
50 for component in joined.components() {
51 match component {
52 std::path::Component::ParentDir => {
53 if !normalized.pop() {
55 return Err(MojenticError::ToolError(format!(
57 "Path {:?} attempts to escape the sandbox",
58 path
59 )));
60 }
61 }
62 std::path::Component::CurDir => {
63 }
65 _ => {
66 normalized.push(component);
67 }
68 }
69 }
70 normalized
71 }
72 };
73
74 if !resolved.starts_with(&self.base_path) {
76 return Err(MojenticError::ToolError(format!(
77 "Path {:?} attempts to escape the sandbox",
78 path
79 )));
80 }
81
82 Ok(resolved)
83 }
84
85 pub fn ls<P: AsRef<Path>>(&self, path: P) -> Result<Vec<String>> {
87 let resolved_path = self.resolve_path(path)?;
88 let entries = fs::read_dir(&resolved_path)?;
89
90 let mut files = Vec::new();
91 for entry in entries {
92 let entry = entry?;
93 let relative = entry
94 .path()
95 .strip_prefix(&self.base_path)
96 .unwrap()
97 .to_string_lossy()
98 .to_string();
99 files.push(relative);
100 }
101
102 Ok(files)
103 }
104
105 pub fn list_all_files<P: AsRef<Path>>(&self, path: P) -> Result<Vec<String>> {
107 let resolved_path = self.resolve_path(path)?;
108 let mut files = Vec::new();
109
110 self.collect_files_recursively(&resolved_path, &mut files)?;
111
112 Ok(files)
113 }
114
115 fn collect_files_recursively(&self, dir: &Path, files: &mut Vec<String>) -> Result<()> {
116 if !dir.is_dir() {
117 return Ok(());
118 }
119
120 let entries = fs::read_dir(dir)?;
121
122 for entry in entries {
123 let entry = entry?;
124 let path = entry.path();
125
126 if path.is_dir() {
127 self.collect_files_recursively(&path, files)?;
128 } else {
129 let relative =
130 path.strip_prefix(&self.base_path).unwrap().to_string_lossy().to_string();
131 files.push(relative);
132 }
133 }
134
135 Ok(())
136 }
137
138 pub fn find_files_by_glob<P: AsRef<Path>>(
140 &self,
141 path: P,
142 pattern: &str,
143 ) -> Result<Vec<String>> {
144 let resolved_path = self.resolve_path(path)?;
145 let glob_pattern = resolved_path.join(pattern);
146 let glob_str = glob_pattern.to_string_lossy();
147
148 let mut files = Vec::new();
149 for entry in glob::glob(&glob_str)
150 .map_err(|e| MojenticError::ToolError(format!("Invalid glob pattern: {}", e)))?
151 {
152 match entry {
153 Ok(path) => {
154 if let Ok(relative) = path.strip_prefix(&self.base_path) {
155 files.push(relative.to_string_lossy().to_string());
156 }
157 }
158 Err(_) => continue,
159 }
160 }
161
162 Ok(files)
163 }
164
165 pub fn find_files_containing<P: AsRef<Path>>(
167 &self,
168 path: P,
169 pattern: &str,
170 ) -> Result<Vec<String>> {
171 let resolved_path = self.resolve_path(path)?;
172 let regex = Regex::new(pattern)
173 .map_err(|e| MojenticError::ToolError(format!("Invalid regex pattern: {}", e)))?;
174
175 let mut files = Vec::new();
176 self.find_matching_files(&resolved_path, ®ex, &mut files)?;
177
178 Ok(files)
179 }
180
181 fn find_matching_files(
182 &self,
183 dir: &Path,
184 regex: &Regex,
185 files: &mut Vec<String>,
186 ) -> Result<()> {
187 if !dir.is_dir() {
188 return Ok(());
189 }
190
191 let entries = fs::read_dir(dir)?;
192
193 for entry in entries {
194 let entry = entry?;
195 let path = entry.path();
196
197 if path.is_dir() {
198 self.find_matching_files(&path, regex, files)?;
199 } else if path.is_file() {
200 if let Ok(content) = fs::read_to_string(&path) {
201 if regex.is_match(&content) {
202 let relative = path
203 .strip_prefix(&self.base_path)
204 .unwrap()
205 .to_string_lossy()
206 .to_string();
207 files.push(relative);
208 }
209 }
210 }
211 }
212
213 Ok(())
214 }
215
216 pub fn find_lines_matching<P: AsRef<Path>>(
218 &self,
219 path: P,
220 file_name: &str,
221 pattern: &str,
222 ) -> Result<Vec<Value>> {
223 let resolved_path = self.resolve_path(path)?;
224 let file_path = resolved_path.join(file_name);
225 let regex = Regex::new(pattern)
226 .map_err(|e| MojenticError::ToolError(format!("Invalid regex pattern: {}", e)))?;
227
228 let content = fs::read_to_string(&file_path)?;
229 let mut matching_lines = Vec::new();
230
231 for (i, line) in content.lines().enumerate() {
232 if regex.is_match(line) {
233 matching_lines.push(json!({
234 "line_number": i + 1,
235 "content": line
236 }));
237 }
238 }
239
240 Ok(matching_lines)
241 }
242
243 pub fn read<P: AsRef<Path>>(&self, path: P, file_name: &str) -> Result<String> {
245 let resolved_path = self.resolve_path(path)?;
246 let file_path = resolved_path.join(file_name);
247 Ok(fs::read_to_string(file_path)?)
248 }
249
250 pub fn write<P: AsRef<Path>>(&self, path: P, file_name: &str, content: &str) -> Result<()> {
252 let resolved_path = self.resolve_path(path)?;
253 let file_path = resolved_path.join(file_name);
254 fs::write(file_path, content)?;
255 Ok(())
256 }
257}
258
259#[derive(Clone)]
261pub struct ListFilesTool {
262 fs: FilesystemGateway,
263}
264
265impl ListFilesTool {
266 pub fn new(fs: FilesystemGateway) -> Self {
267 Self { fs }
268 }
269}
270
271impl LlmTool for ListFilesTool {
272 fn descriptor(&self) -> ToolDescriptor {
273 ToolDescriptor {
274 r#type: "function".to_string(),
275 function: FunctionDescriptor {
276 name: "list_files".to_string(),
277 description: "List files in the specified directory (non-recursive), optionally filtered by extension. Use this when you need to see what files are available in a specific directory without including subdirectories.".to_string(),
278 parameters: json!({
279 "type": "object",
280 "properties": {
281 "path": {
282 "type": "string",
283 "description": "The path relative to the sandbox root to list files from. For example, '.' for the root directory, 'src' for the src directory, or 'docs/images' for a nested directory."
284 },
285 "extension": {
286 "type": "string",
287 "description": "The file extension to filter by (e.g., '.py', '.txt', '.md'). If not provided, all files will be listed. For example, using '.py' will only list Python files in the directory."
288 }
289 },
290 "additionalProperties": false,
291 "required": ["path"]
292 }),
293 },
294 }
295 }
296
297 fn run(&self, args: &HashMap<String, Value>) -> Result<Value> {
298 let path = args
299 .get("path")
300 .and_then(|v| v.as_str())
301 .ok_or_else(|| MojenticError::ToolError("Missing 'path' parameter".to_string()))?;
302
303 let extension = args.get("extension").and_then(|v| v.as_str());
304
305 let files = self.fs.ls(path)?;
306
307 let filtered: Vec<String> = if let Some(ext) = extension {
308 files.into_iter().filter(|f| f.ends_with(ext)).collect()
309 } else {
310 files
311 };
312
313 Ok(json!(filtered))
314 }
315 fn clone_box(&self) -> Box<dyn LlmTool> {
316 Box::new(self.clone())
317 }
318}
319
320#[derive(Clone)]
322pub struct ReadFileTool {
323 fs: FilesystemGateway,
324}
325
326impl ReadFileTool {
327 pub fn new(fs: FilesystemGateway) -> Self {
328 Self { fs }
329 }
330}
331
332impl LlmTool for ReadFileTool {
333 fn descriptor(&self) -> ToolDescriptor {
334 ToolDescriptor {
335 r#type: "function".to_string(),
336 function: FunctionDescriptor {
337 name: "read_file".to_string(),
338 description: "Read the entire content of a file as a string. Use this when you need to access or analyze the complete contents of a file.".to_string(),
339 parameters: json!({
340 "type": "object",
341 "properties": {
342 "path": {
343 "type": "string",
344 "description": "The full relative path including the filename of the file to read. For example, 'README.md' for a file in the root directory, 'src/main.py' for a file in the src directory, or 'docs/images/diagram.png' for a file in a nested directory."
345 }
346 },
347 "additionalProperties": false,
348 "required": ["path"]
349 }),
350 },
351 }
352 }
353
354 fn run(&self, args: &HashMap<String, Value>) -> Result<Value> {
355 let path = args
356 .get("path")
357 .and_then(|v| v.as_str())
358 .ok_or_else(|| MojenticError::ToolError("Missing 'path' parameter".to_string()))?;
359
360 let (directory, file_name) = split_path(path);
361 let content = self.fs.read(directory, file_name)?;
362 Ok(json!(content))
363 }
364 fn clone_box(&self) -> Box<dyn LlmTool> {
365 Box::new(self.clone())
366 }
367}
368
369#[derive(Clone)]
371pub struct WriteFileTool {
372 fs: FilesystemGateway,
373}
374
375impl WriteFileTool {
376 pub fn new(fs: FilesystemGateway) -> Self {
377 Self { fs }
378 }
379}
380
381impl LlmTool for WriteFileTool {
382 fn descriptor(&self) -> ToolDescriptor {
383 ToolDescriptor {
384 r#type: "function".to_string(),
385 function: FunctionDescriptor {
386 name: "write_file".to_string(),
387 description: "Write content to a file, completely overwriting any existing content. Use this when you want to replace the entire contents of a file with new content.".to_string(),
388 parameters: json!({
389 "type": "object",
390 "properties": {
391 "path": {
392 "type": "string",
393 "description": "The full relative path including the filename where the file should be written. For example, 'output.txt' for a file in the root directory, 'src/main.py' for a file in the src directory, or 'docs/images/diagram.png' for a file in a nested directory."
394 },
395 "content": {
396 "type": "string",
397 "description": "The content to write to the file. This will completely replace any existing content in the file. For example, 'Hello, world!' for a simple text file, or a JSON string for a configuration file."
398 }
399 },
400 "additionalProperties": false,
401 "required": ["path", "content"]
402 }),
403 },
404 }
405 }
406
407 fn run(&self, args: &HashMap<String, Value>) -> Result<Value> {
408 let path = args
409 .get("path")
410 .and_then(|v| v.as_str())
411 .ok_or_else(|| MojenticError::ToolError("Missing 'path' parameter".to_string()))?;
412
413 let content = args
414 .get("content")
415 .and_then(|v| v.as_str())
416 .ok_or_else(|| MojenticError::ToolError("Missing 'content' parameter".to_string()))?;
417
418 let (directory, file_name) = split_path(path);
419 self.fs.write(directory, file_name, content)?;
420 Ok(json!(format!("Successfully wrote to {}", path)))
421 }
422 fn clone_box(&self) -> Box<dyn LlmTool> {
423 Box::new(self.clone())
424 }
425}
426
427#[derive(Clone)]
429pub struct ListAllFilesTool {
430 fs: FilesystemGateway,
431}
432
433impl ListAllFilesTool {
434 pub fn new(fs: FilesystemGateway) -> Self {
435 Self { fs }
436 }
437}
438
439impl LlmTool for ListAllFilesTool {
440 fn descriptor(&self) -> ToolDescriptor {
441 ToolDescriptor {
442 r#type: "function".to_string(),
443 function: FunctionDescriptor {
444 name: "list_all_files".to_string(),
445 description: "List all files recursively in the specified directory, including files in subdirectories. Use this when you need a complete inventory of all files in a directory and its subdirectories.".to_string(),
446 parameters: json!({
447 "type": "object",
448 "properties": {
449 "path": {
450 "type": "string",
451 "description": "The path relative to the sandbox root to list files from recursively. For example, '.' for the root directory and all subdirectories, 'src' for the src directory and all its subdirectories, or 'docs/images' for a nested directory and its subdirectories."
452 }
453 },
454 "additionalProperties": false,
455 "required": ["path"]
456 }),
457 },
458 }
459 }
460
461 fn run(&self, args: &HashMap<String, Value>) -> Result<Value> {
462 let path = args
463 .get("path")
464 .and_then(|v| v.as_str())
465 .ok_or_else(|| MojenticError::ToolError("Missing 'path' parameter".to_string()))?;
466
467 let files = self.fs.list_all_files(path)?;
468 Ok(json!(files))
469 }
470 fn clone_box(&self) -> Box<dyn LlmTool> {
471 Box::new(self.clone())
472 }
473}
474
475#[derive(Clone)]
477pub struct FindFilesByGlobTool {
478 fs: FilesystemGateway,
479}
480
481impl FindFilesByGlobTool {
482 pub fn new(fs: FilesystemGateway) -> Self {
483 Self { fs }
484 }
485}
486
487impl LlmTool for FindFilesByGlobTool {
488 fn descriptor(&self) -> ToolDescriptor {
489 ToolDescriptor {
490 r#type: "function".to_string(),
491 function: FunctionDescriptor {
492 name: "find_files_by_glob".to_string(),
493 description: "Find files matching a glob pattern in the specified directory. Use this when you need to locate files with specific patterns in their names or paths (e.g., all Python files with '*.py' or all text files in any subdirectory with '**/*.txt').".to_string(),
494 parameters: json!({
495 "type": "object",
496 "properties": {
497 "path": {
498 "type": "string",
499 "description": "The path relative to the sandbox root to search in. For example, '.' for the root directory, 'src' for the src directory, or 'docs/images' for a nested directory."
500 },
501 "pattern": {
502 "type": "string",
503 "description": "The glob pattern to match files against. Examples: '*.py' for all Python files in the specified directory, '**/*.txt' for all text files in the specified directory and any subdirectory, or '**/*test*.py' for all Python files with 'test' in their name in the specified directory and any subdirectory."
504 }
505 },
506 "additionalProperties": false,
507 "required": ["path", "pattern"]
508 }),
509 },
510 }
511 }
512
513 fn run(&self, args: &HashMap<String, Value>) -> Result<Value> {
514 let path = args
515 .get("path")
516 .and_then(|v| v.as_str())
517 .ok_or_else(|| MojenticError::ToolError("Missing 'path' parameter".to_string()))?;
518
519 let pattern = args
520 .get("pattern")
521 .and_then(|v| v.as_str())
522 .ok_or_else(|| MojenticError::ToolError("Missing 'pattern' parameter".to_string()))?;
523
524 let files = self.fs.find_files_by_glob(path, pattern)?;
525 Ok(json!(files))
526 }
527 fn clone_box(&self) -> Box<dyn LlmTool> {
528 Box::new(self.clone())
529 }
530}
531
532#[derive(Clone)]
534pub struct FindFilesContainingTool {
535 fs: FilesystemGateway,
536}
537
538impl FindFilesContainingTool {
539 pub fn new(fs: FilesystemGateway) -> Self {
540 Self { fs }
541 }
542}
543
544impl LlmTool for FindFilesContainingTool {
545 fn descriptor(&self) -> ToolDescriptor {
546 ToolDescriptor {
547 r#type: "function".to_string(),
548 function: FunctionDescriptor {
549 name: "find_files_containing".to_string(),
550 description: "Find files containing text matching a regex pattern in the specified directory. Use this when you need to search for specific content across multiple files, such as finding all files that contain a particular function name or text string.".to_string(),
551 parameters: json!({
552 "type": "object",
553 "properties": {
554 "path": {
555 "type": "string",
556 "description": "The path relative to the sandbox root to search in. For example, '.' for the root directory, 'src' for the src directory, or 'docs/images' for a nested directory."
557 },
558 "pattern": {
559 "type": "string",
560 "description": "The regex pattern to search for in files. Examples: 'function\\s+main' to find files containing a main function, 'import\\s+os' to find files importing the os module, or 'TODO|FIXME' to find files containing TODO or FIXME comments. The pattern uses Rust's regex crate syntax."
561 }
562 },
563 "additionalProperties": false,
564 "required": ["path", "pattern"]
565 }),
566 },
567 }
568 }
569
570 fn run(&self, args: &HashMap<String, Value>) -> Result<Value> {
571 let path = args
572 .get("path")
573 .and_then(|v| v.as_str())
574 .ok_or_else(|| MojenticError::ToolError("Missing 'path' parameter".to_string()))?;
575
576 let pattern = args
577 .get("pattern")
578 .and_then(|v| v.as_str())
579 .ok_or_else(|| MojenticError::ToolError("Missing 'pattern' parameter".to_string()))?;
580
581 let files = self.fs.find_files_containing(path, pattern)?;
582 Ok(json!(files))
583 }
584 fn clone_box(&self) -> Box<dyn LlmTool> {
585 Box::new(self.clone())
586 }
587}
588
589#[derive(Clone)]
591pub struct FindLinesMatchingTool {
592 fs: FilesystemGateway,
593}
594
595impl FindLinesMatchingTool {
596 pub fn new(fs: FilesystemGateway) -> Self {
597 Self { fs }
598 }
599}
600
601impl LlmTool for FindLinesMatchingTool {
602 fn descriptor(&self) -> ToolDescriptor {
603 ToolDescriptor {
604 r#type: "function".to_string(),
605 function: FunctionDescriptor {
606 name: "find_lines_matching".to_string(),
607 description: "Find all lines in a file matching a regex pattern, returning both line numbers and content. Use this when you need to locate specific patterns within a single file and need to know exactly where they appear.".to_string(),
608 parameters: json!({
609 "type": "object",
610 "properties": {
611 "path": {
612 "type": "string",
613 "description": "The full relative path including the filename of the file to search in. For example, 'README.md' for a file in the root directory, 'src/main.py' for a file in the src directory, or 'docs/images/diagram.png' for a file in a nested directory."
614 },
615 "pattern": {
616 "type": "string",
617 "description": "The regex pattern to match lines against. Examples: 'def\\s+\\w+' to find all function definitions, 'class\\s+\\w+' to find all class definitions, or 'TODO|FIXME' to find all TODO or FIXME comments. The pattern uses Rust's regex crate syntax."
618 }
619 },
620 "additionalProperties": false,
621 "required": ["path", "pattern"]
622 }),
623 },
624 }
625 }
626
627 fn run(&self, args: &HashMap<String, Value>) -> Result<Value> {
628 let path = args
629 .get("path")
630 .and_then(|v| v.as_str())
631 .ok_or_else(|| MojenticError::ToolError("Missing 'path' parameter".to_string()))?;
632
633 let pattern = args
634 .get("pattern")
635 .and_then(|v| v.as_str())
636 .ok_or_else(|| MojenticError::ToolError("Missing 'pattern' parameter".to_string()))?;
637
638 let (directory, file_name) = split_path(path);
639 let lines = self.fs.find_lines_matching(directory, file_name, pattern)?;
640 Ok(json!(lines))
641 }
642 fn clone_box(&self) -> Box<dyn LlmTool> {
643 Box::new(self.clone())
644 }
645}
646
647#[derive(Clone)]
649pub struct CreateDirectoryTool {
650 fs: FilesystemGateway,
651}
652
653impl CreateDirectoryTool {
654 pub fn new(fs: FilesystemGateway) -> Self {
655 Self { fs }
656 }
657}
658
659impl LlmTool for CreateDirectoryTool {
660 fn descriptor(&self) -> ToolDescriptor {
661 ToolDescriptor {
662 r#type: "function".to_string(),
663 function: FunctionDescriptor {
664 name: "create_directory".to_string(),
665 description: "Create a new directory at the specified path. If the directory already exists, this operation will succeed without error. Use this when you need to create a directory structure before writing files to it.".to_string(),
666 parameters: json!({
667 "type": "object",
668 "properties": {
669 "path": {
670 "type": "string",
671 "description": "The relative path where the directory should be created. For example, 'new_folder' for a directory in the root, 'src/new_folder' for a directory in the src directory, or 'docs/images/new_folder' for a nested directory. Parent directories will be created automatically if they don't exist."
672 }
673 },
674 "additionalProperties": false,
675 "required": ["path"]
676 }),
677 },
678 }
679 }
680
681 fn run(&self, args: &HashMap<String, Value>) -> Result<Value> {
682 let path = args
683 .get("path")
684 .and_then(|v| v.as_str())
685 .ok_or_else(|| MojenticError::ToolError("Missing 'path' parameter".to_string()))?;
686
687 let resolved_path = self.fs.resolve_path(path)?;
688 fs::create_dir_all(&resolved_path)?;
689 Ok(json!(format!("Successfully created directory '{}'", path)))
690 }
691 fn clone_box(&self) -> Box<dyn LlmTool> {
692 Box::new(self.clone())
693 }
694}
695
696fn split_path(path: &str) -> (&str, &str) {
697 let path_obj = Path::new(path);
698 let directory = path_obj.parent().and_then(|p| p.to_str()).unwrap_or(".");
699 let file_name = path_obj.file_name().and_then(|f| f.to_str()).unwrap_or("");
700 (directory, file_name)
701}
702
703#[cfg(test)]
704mod tests {
705 use super::*;
706 use tempfile::TempDir;
707
708 #[test]
709 fn test_filesystem_gateway_new() {
710 let temp_dir = TempDir::new().unwrap();
711 let gateway = FilesystemGateway::new(temp_dir.path());
712 assert!(gateway.is_ok());
713 }
714
715 #[test]
716 fn test_resolve_path_security() {
717 let temp_dir = TempDir::new().unwrap();
718 let gateway = FilesystemGateway::new(temp_dir.path()).unwrap();
719
720 let result = gateway.resolve_path("../../../etc/passwd");
722 assert!(result.is_err());
723 }
724
725 #[test]
726 fn test_ls() {
727 let temp_dir = TempDir::new().unwrap();
728 fs::write(temp_dir.path().join("test.txt"), "content").unwrap();
729
730 let gateway = FilesystemGateway::new(temp_dir.path()).unwrap();
731 let files = gateway.ls(".").unwrap();
732
733 assert_eq!(files.len(), 1);
734 assert!(files[0].contains("test.txt"));
735 }
736
737 #[test]
738 fn test_read_write() {
739 let temp_dir = TempDir::new().unwrap();
740 let gateway = FilesystemGateway::new(temp_dir.path()).unwrap();
741
742 gateway.write(".", "test.txt", "Hello, world!").unwrap();
743 let content = gateway.read(".", "test.txt").unwrap();
744
745 assert_eq!(content, "Hello, world!");
746 }
747}