mojentic/llm/tools/
file_manager.rs

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/// A gateway for interacting with the filesystem within a sandboxed base path.
11///
12/// This struct provides safe filesystem operations that are restricted to a
13/// specific base directory, preventing path traversal attacks.
14#[derive(Debug, Clone)]
15pub struct FilesystemGateway {
16    base_path: PathBuf,
17}
18
19impl FilesystemGateway {
20    /// Creates a new FilesystemGateway with the specified base path.
21    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    /// Resolves a path relative to the base path and ensures it stays within the sandbox.
37    pub fn resolve_path<P: AsRef<Path>>(&self, path: P) -> Result<PathBuf> {
38        let path = path.as_ref();
39
40        // First try to canonicalize the full path
41        let resolved = match self.base_path.join(path).canonicalize() {
42            Ok(canonical) => canonical,
43            Err(_) => {
44                // If canonicalize fails (file doesn't exist yet), manually normalize
45                // We need to ensure we still catch attempts to escape
46                let joined = self.base_path.join(path);
47
48                // Normalize by removing .. and . components
49                let mut normalized = PathBuf::new();
50                for component in joined.components() {
51                    match component {
52                        std::path::Component::ParentDir => {
53                            // Pop the last component if possible
54                            if !normalized.pop() {
55                                // Trying to go above root - this is an escape attempt
56                                return Err(MojenticError::ToolError(format!(
57                                    "Path {:?} attempts to escape the sandbox",
58                                    path
59                                )));
60                            }
61                        }
62                        std::path::Component::CurDir => {
63                            // Skip current directory markers
64                        }
65                        _ => {
66                            normalized.push(component);
67                        }
68                    }
69                }
70                normalized
71            }
72        };
73
74        // Verify the resolved path is within the sandbox
75        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    /// Lists files in a directory (non-recursive).
86    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    /// Lists all files recursively in a directory.
106    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    /// Finds files matching a glob pattern.
139    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    /// Finds files containing text matching a regex pattern.
166    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, &regex, &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    /// Finds all lines in a file matching a regex pattern.
217    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    /// Reads the content of a file.
244    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    /// Writes content to a file.
251    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/// Tool for listing files in a directory (non-recursive).
260#[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/// Tool for reading the entire content of a file.
321#[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/// Tool for writing content to a file, completely overwriting any existing content.
370#[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/// Tool for listing all files recursively in a directory.
428#[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/// Tool for finding files matching a glob pattern.
476#[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/// Tool for finding files containing text matching a regex pattern.
533#[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/// Tool for finding all lines in a file matching a regex pattern.
590#[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/// Tool for creating a new directory.
648#[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        // Should fail - trying to escape sandbox
721        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}