Skip to main content

ai_agents_tools/builtin/
file.rs

1use async_trait::async_trait;
2use schemars::JsonSchema;
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5use std::fs;
6use std::path::Path;
7
8use crate::generate_schema;
9use ai_agents_core::{Tool, ToolResult};
10
11pub struct FileTool;
12
13impl FileTool {
14    pub fn new() -> Self {
15        Self
16    }
17}
18
19impl Default for FileTool {
20    fn default() -> Self {
21        Self::new()
22    }
23}
24
25#[derive(Debug, Deserialize, JsonSchema)]
26struct FileInput {
27    /// Operation: read, write, append, exists, delete, list, mkdir, info
28    operation: String,
29    /// File or directory path
30    path: String,
31    /// Content to write (for write/append)
32    #[serde(default)]
33    content: Option<String>,
34    /// Glob pattern for list operation (e.g., '*.json')
35    #[serde(default)]
36    pattern: Option<String>,
37}
38
39#[derive(Debug, Serialize)]
40struct ReadOutput {
41    content: String,
42    path: String,
43    size: usize,
44}
45
46#[derive(Debug, Serialize)]
47struct WriteOutput {
48    success: bool,
49    path: String,
50    bytes_written: usize,
51}
52
53#[derive(Debug, Serialize)]
54struct ExistsOutput {
55    exists: bool,
56    path: String,
57    is_file: bool,
58    is_dir: bool,
59}
60
61#[derive(Debug, Serialize)]
62struct DeleteOutput {
63    success: bool,
64    path: String,
65}
66
67#[derive(Debug, Serialize)]
68struct ListOutput {
69    entries: Vec<ListEntry>,
70    path: String,
71    count: usize,
72}
73
74#[derive(Debug, Serialize)]
75struct ListEntry {
76    name: String,
77    path: String,
78    is_file: bool,
79    is_dir: bool,
80    size: Option<u64>,
81}
82
83#[derive(Debug, Serialize)]
84struct MkdirOutput {
85    success: bool,
86    path: String,
87}
88
89#[derive(Debug, Serialize)]
90struct InfoOutput {
91    path: String,
92    exists: bool,
93    is_file: bool,
94    is_dir: bool,
95    size: Option<u64>,
96    #[serde(skip_serializing_if = "Option::is_none")]
97    modified: Option<String>,
98    #[serde(skip_serializing_if = "Option::is_none")]
99    created: Option<String>,
100}
101
102#[async_trait]
103impl Tool for FileTool {
104    fn id(&self) -> &str {
105        "file"
106    }
107
108    fn name(&self) -> &str {
109        "File Operations"
110    }
111
112    fn description(&self) -> &str {
113        "Read, write, and manage files. Operations: read (read file content), write (write content to file), append (append to file), exists (check if path exists), delete (delete file/directory), list (list directory contents), mkdir (create directory), info (get file metadata)."
114    }
115
116    fn input_schema(&self) -> Value {
117        generate_schema::<FileInput>()
118    }
119
120    async fn execute(&self, args: Value) -> ToolResult {
121        let input: FileInput = match serde_json::from_value(args) {
122            Ok(input) => input,
123            Err(e) => return ToolResult::error(format!("Invalid input: {}", e)),
124        };
125
126        match input.operation.to_lowercase().as_str() {
127            "read" => self.handle_read(&input),
128            "write" => self.handle_write(&input),
129            "append" => self.handle_append(&input),
130            "exists" => self.handle_exists(&input),
131            "delete" => self.handle_delete(&input),
132            "list" => self.handle_list(&input),
133            "mkdir" => self.handle_mkdir(&input),
134            "info" => self.handle_info(&input),
135            _ => ToolResult::error(format!(
136                "Unknown operation: {}. Valid: read, write, append, exists, delete, list, mkdir, info",
137                input.operation
138            )),
139        }
140    }
141}
142
143impl FileTool {
144    fn handle_read(&self, input: &FileInput) -> ToolResult {
145        match fs::read_to_string(&input.path) {
146            Ok(content) => {
147                let output = ReadOutput {
148                    size: content.len(),
149                    content,
150                    path: input.path.clone(),
151                };
152                self.to_result(&output)
153            }
154            Err(e) => ToolResult::error(format!("Read error: {}", e)),
155        }
156    }
157
158    fn handle_write(&self, input: &FileInput) -> ToolResult {
159        let content = input.content.as_deref().unwrap_or("");
160        match fs::write(&input.path, content) {
161            Ok(_) => {
162                let output = WriteOutput {
163                    success: true,
164                    path: input.path.clone(),
165                    bytes_written: content.len(),
166                };
167                self.to_result(&output)
168            }
169            Err(e) => ToolResult::error(format!("Write error: {}", e)),
170        }
171    }
172
173    fn handle_append(&self, input: &FileInput) -> ToolResult {
174        use std::fs::OpenOptions;
175        use std::io::Write;
176
177        let content = input.content.as_deref().unwrap_or("");
178        let file = OpenOptions::new()
179            .create(true)
180            .append(true)
181            .open(&input.path);
182
183        match file {
184            Ok(mut f) => match f.write_all(content.as_bytes()) {
185                Ok(_) => {
186                    let output = WriteOutput {
187                        success: true,
188                        path: input.path.clone(),
189                        bytes_written: content.len(),
190                    };
191                    self.to_result(&output)
192                }
193                Err(e) => ToolResult::error(format!("Append error: {}", e)),
194            },
195            Err(e) => ToolResult::error(format!("File open error: {}", e)),
196        }
197    }
198
199    fn handle_exists(&self, input: &FileInput) -> ToolResult {
200        let path = Path::new(&input.path);
201        let output = ExistsOutput {
202            exists: path.exists(),
203            path: input.path.clone(),
204            is_file: path.is_file(),
205            is_dir: path.is_dir(),
206        };
207        self.to_result(&output)
208    }
209
210    fn handle_delete(&self, input: &FileInput) -> ToolResult {
211        let path = Path::new(&input.path);
212        let result = if path.is_dir() {
213            fs::remove_dir_all(path)
214        } else {
215            fs::remove_file(path)
216        };
217
218        match result {
219            Ok(_) => {
220                let output = DeleteOutput {
221                    success: true,
222                    path: input.path.clone(),
223                };
224                self.to_result(&output)
225            }
226            Err(e) => ToolResult::error(format!("Delete error: {}", e)),
227        }
228    }
229
230    fn handle_list(&self, input: &FileInput) -> ToolResult {
231        let path = Path::new(&input.path);
232        if !path.is_dir() {
233            return ToolResult::error(format!("Not a directory: {}", input.path));
234        }
235
236        let pattern = input.pattern.as_deref();
237
238        match fs::read_dir(path) {
239            Ok(entries) => {
240                let mut list_entries = Vec::new();
241
242                for entry in entries.flatten() {
243                    let file_name = entry.file_name().to_string_lossy().to_string();
244
245                    if let Some(pat) = pattern {
246                        if !self.matches_pattern(&file_name, pat) {
247                            continue;
248                        }
249                    }
250
251                    let metadata = entry.metadata().ok();
252                    let entry_path = entry.path();
253
254                    list_entries.push(ListEntry {
255                        name: file_name,
256                        path: entry_path.to_string_lossy().to_string(),
257                        is_file: entry_path.is_file(),
258                        is_dir: entry_path.is_dir(),
259                        size: metadata.map(|m| m.len()),
260                    });
261                }
262
263                let output = ListOutput {
264                    count: list_entries.len(),
265                    entries: list_entries,
266                    path: input.path.clone(),
267                };
268                self.to_result(&output)
269            }
270            Err(e) => ToolResult::error(format!("List error: {}", e)),
271        }
272    }
273
274    fn handle_mkdir(&self, input: &FileInput) -> ToolResult {
275        match fs::create_dir_all(&input.path) {
276            Ok(_) => {
277                let output = MkdirOutput {
278                    success: true,
279                    path: input.path.clone(),
280                };
281                self.to_result(&output)
282            }
283            Err(e) => ToolResult::error(format!("Mkdir error: {}", e)),
284        }
285    }
286
287    fn handle_info(&self, input: &FileInput) -> ToolResult {
288        let path = Path::new(&input.path);
289
290        if !path.exists() {
291            let output = InfoOutput {
292                path: input.path.clone(),
293                exists: false,
294                is_file: false,
295                is_dir: false,
296                size: None,
297                modified: None,
298                created: None,
299            };
300            return self.to_result(&output);
301        }
302
303        let metadata = match fs::metadata(path) {
304            Ok(m) => m,
305            Err(e) => return ToolResult::error(format!("Metadata error: {}", e)),
306        };
307
308        let modified = metadata.modified().ok().map(|t| {
309            let datetime: chrono::DateTime<chrono::Utc> = t.into();
310            datetime.to_rfc3339()
311        });
312
313        let created = metadata.created().ok().map(|t| {
314            let datetime: chrono::DateTime<chrono::Utc> = t.into();
315            datetime.to_rfc3339()
316        });
317
318        let output = InfoOutput {
319            path: input.path.clone(),
320            exists: true,
321            is_file: metadata.is_file(),
322            is_dir: metadata.is_dir(),
323            size: Some(metadata.len()),
324            modified,
325            created,
326        };
327        self.to_result(&output)
328    }
329
330    fn matches_pattern(&self, name: &str, pattern: &str) -> bool {
331        let pattern = pattern.trim();
332        if pattern.is_empty() || pattern == "*" {
333            return true;
334        }
335
336        if pattern.starts_with("*.") {
337            let ext = &pattern[2..];
338            return name.ends_with(&format!(".{}", ext));
339        }
340
341        if pattern.ends_with(".*") {
342            let prefix = &pattern[..pattern.len() - 2];
343            return name.starts_with(prefix);
344        }
345
346        if pattern.starts_with('*') && pattern.ends_with('*') {
347            let middle = &pattern[1..pattern.len() - 1];
348            return name.contains(middle);
349        }
350
351        name == pattern
352    }
353
354    fn to_result<T: Serialize>(&self, output: &T) -> ToolResult {
355        match serde_json::to_string(output) {
356            Ok(json) => ToolResult::ok(json),
357            Err(e) => ToolResult::error(format!("Serialization error: {}", e)),
358        }
359    }
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365    use std::fs;
366    use tempfile::tempdir;
367
368    #[tokio::test]
369    async fn test_write_and_read() {
370        let dir = tempdir().unwrap();
371        let file_path = dir.path().join("test.txt");
372        let path_str = file_path.to_str().unwrap();
373        let tool = FileTool::new();
374
375        let result = tool
376            .execute(serde_json::json!({
377                "operation": "write",
378                "path": path_str,
379                "content": "hello world"
380            }))
381            .await;
382        assert!(result.success);
383
384        let result = tool
385            .execute(serde_json::json!({
386                "operation": "read",
387                "path": path_str
388            }))
389            .await;
390        assert!(result.success);
391        assert!(result.output.contains("hello world"));
392    }
393
394    #[tokio::test]
395    async fn test_append() {
396        let dir = tempdir().unwrap();
397        let file_path = dir.path().join("append.txt");
398        let path_str = file_path.to_str().unwrap();
399        let tool = FileTool::new();
400
401        tool.execute(serde_json::json!({
402            "operation": "write",
403            "path": path_str,
404            "content": "line1\n"
405        }))
406        .await;
407
408        tool.execute(serde_json::json!({
409            "operation": "append",
410            "path": path_str,
411            "content": "line2\n"
412        }))
413        .await;
414
415        let content = fs::read_to_string(&file_path).unwrap();
416        assert!(content.contains("line1"));
417        assert!(content.contains("line2"));
418    }
419
420    #[tokio::test]
421    async fn test_exists() {
422        let dir = tempdir().unwrap();
423        let file_path = dir.path().join("exists.txt");
424        let path_str = file_path.to_str().unwrap();
425        let tool = FileTool::new();
426
427        let result = tool
428            .execute(serde_json::json!({
429                "operation": "exists",
430                "path": path_str
431            }))
432            .await;
433        assert!(result.success);
434        assert!(result.output.contains("\"exists\":false"));
435
436        fs::write(&file_path, "test").unwrap();
437
438        let result = tool
439            .execute(serde_json::json!({
440                "operation": "exists",
441                "path": path_str
442            }))
443            .await;
444        assert!(result.success);
445        assert!(result.output.contains("\"exists\":true"));
446    }
447
448    #[tokio::test]
449    async fn test_delete() {
450        let dir = tempdir().unwrap();
451        let file_path = dir.path().join("delete.txt");
452        let path_str = file_path.to_str().unwrap();
453        let tool = FileTool::new();
454
455        fs::write(&file_path, "test").unwrap();
456        assert!(file_path.exists());
457
458        let result = tool
459            .execute(serde_json::json!({
460                "operation": "delete",
461                "path": path_str
462            }))
463            .await;
464        assert!(result.success);
465        assert!(!file_path.exists());
466    }
467
468    #[tokio::test]
469    async fn test_list() {
470        let dir = tempdir().unwrap();
471        let tool = FileTool::new();
472
473        fs::write(dir.path().join("a.txt"), "a").unwrap();
474        fs::write(dir.path().join("b.json"), "b").unwrap();
475        fs::write(dir.path().join("c.txt"), "c").unwrap();
476
477        let result = tool
478            .execute(serde_json::json!({
479                "operation": "list",
480                "path": dir.path().to_str().unwrap()
481            }))
482            .await;
483        assert!(result.success);
484        assert!(result.output.contains("\"count\":3"));
485
486        let result = tool
487            .execute(serde_json::json!({
488                "operation": "list",
489                "path": dir.path().to_str().unwrap(),
490                "pattern": "*.txt"
491            }))
492            .await;
493        assert!(result.success);
494        assert!(result.output.contains("\"count\":2"));
495    }
496
497    #[tokio::test]
498    async fn test_mkdir() {
499        let dir = tempdir().unwrap();
500        let new_dir = dir.path().join("new/nested/dir");
501        let tool = FileTool::new();
502
503        let result = tool
504            .execute(serde_json::json!({
505                "operation": "mkdir",
506                "path": new_dir.to_str().unwrap()
507            }))
508            .await;
509        assert!(result.success);
510        assert!(new_dir.exists());
511    }
512
513    #[tokio::test]
514    async fn test_info() {
515        let dir = tempdir().unwrap();
516        let file_path = dir.path().join("info.txt");
517        let tool = FileTool::new();
518
519        fs::write(&file_path, "test content").unwrap();
520
521        let result = tool
522            .execute(serde_json::json!({
523                "operation": "info",
524                "path": file_path.to_str().unwrap()
525            }))
526            .await;
527        assert!(result.success);
528        assert!(result.output.contains("\"is_file\":true"));
529        assert!(result.output.contains("\"size\":12"));
530    }
531
532    #[tokio::test]
533    async fn test_invalid_operation() {
534        let tool = FileTool::new();
535        let result = tool
536            .execute(serde_json::json!({
537                "operation": "invalid",
538                "path": "/tmp/test"
539            }))
540            .await;
541        assert!(!result.success);
542    }
543}