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::{
10    PathPolicyBinding, Tool, ToolCallClassification, ToolExecutionContext, ToolOperationKind,
11    ToolPolicyBindings, ToolResult, ToolSafetyMetadata, ToolSideEffectLevel,
12};
13
14pub struct FileTool;
15
16impl FileTool {
17    pub fn new() -> Self {
18        Self
19    }
20}
21
22impl Default for FileTool {
23    fn default() -> Self {
24        Self::new()
25    }
26}
27
28#[derive(Debug, Deserialize, JsonSchema)]
29struct FileInput {
30    /// Operation: read, write, append, exists, delete, list, mkdir, info
31    operation: String,
32    /// File or directory path
33    path: String,
34    /// Content to write (for write/append)
35    #[serde(default)]
36    content: Option<String>,
37    /// Glob pattern for list operation (e.g., '*.json')
38    #[serde(default)]
39    pattern: Option<String>,
40}
41
42#[derive(Debug, Serialize)]
43struct ReadOutput {
44    content: String,
45    path: String,
46    size: usize,
47}
48
49#[derive(Debug, Serialize)]
50struct WriteOutput {
51    success: bool,
52    path: String,
53    bytes_written: usize,
54}
55
56#[derive(Debug, Serialize)]
57struct ExistsOutput {
58    exists: bool,
59    path: String,
60    is_file: bool,
61    is_dir: bool,
62}
63
64#[derive(Debug, Serialize)]
65struct DeleteOutput {
66    success: bool,
67    path: String,
68}
69
70#[derive(Debug, Serialize)]
71struct ListOutput {
72    entries: Vec<ListEntry>,
73    path: String,
74    count: usize,
75}
76
77#[derive(Debug, Serialize)]
78struct ListEntry {
79    name: String,
80    path: String,
81    is_file: bool,
82    is_dir: bool,
83    size: Option<u64>,
84}
85
86#[derive(Debug, Serialize)]
87struct MkdirOutput {
88    success: bool,
89    path: String,
90}
91
92#[derive(Debug, Serialize)]
93struct InfoOutput {
94    path: String,
95    exists: bool,
96    is_file: bool,
97    is_dir: bool,
98    size: Option<u64>,
99    #[serde(skip_serializing_if = "Option::is_none")]
100    modified: Option<String>,
101    #[serde(skip_serializing_if = "Option::is_none")]
102    created: Option<String>,
103}
104
105#[async_trait]
106impl Tool for FileTool {
107    fn id(&self) -> &str {
108        "file"
109    }
110
111    fn name(&self) -> &str {
112        "File Operations"
113    }
114
115    fn description(&self) -> &str {
116        "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)."
117    }
118
119    fn input_schema(&self) -> Value {
120        generate_schema::<FileInput>()
121    }
122
123    fn safety_metadata(&self) -> ToolSafetyMetadata {
124        ToolSafetyMetadata {
125            read_only: false,
126            concurrency_safe: false,
127            operation: ToolOperationKind::Write,
128            side_effect_level: ToolSideEffectLevel::LocalWrite,
129            requires_network: false,
130            destructive: true,
131            open_world: false,
132            host_dependent: false,
133            requires_user_interaction: false,
134            supports_cancellation: false,
135            default_requires_approval: true,
136            should_defer_schema: false,
137            max_output_chars: Some(20_000),
138            max_result_size_chars: Some(20_000),
139        }
140    }
141
142    fn policy_bindings(&self) -> ToolPolicyBindings {
143        ToolPolicyBindings {
144            path_fields: vec![PathPolicyBinding::read_write("path")],
145            operation_fields: vec!["operation".to_string()],
146            ..Default::default()
147        }
148    }
149
150    fn classify_call(&self, args: &Value) -> ToolCallClassification {
151        let operation = args
152            .get("operation")
153            .and_then(|v| v.as_str())
154            .unwrap_or_default()
155            .to_ascii_lowercase();
156        let mut metadata = self.safety_metadata();
157        match operation.as_str() {
158            "read" | "exists" | "list" | "info" => {
159                metadata.read_only = true;
160                metadata.concurrency_safe = true;
161                metadata.operation = ToolOperationKind::Read;
162                metadata.side_effect_level = ToolSideEffectLevel::None;
163                metadata.destructive = false;
164                metadata.default_requires_approval = false;
165            }
166            "delete" => {
167                metadata.operation = ToolOperationKind::Delete;
168                metadata.side_effect_level = ToolSideEffectLevel::Destructive;
169                metadata.destructive = true;
170                metadata.default_requires_approval = true;
171            }
172            "write" | "append" | "mkdir" => {
173                metadata.operation = ToolOperationKind::Write;
174                metadata.side_effect_level = ToolSideEffectLevel::LocalWrite;
175                metadata.destructive = false;
176                metadata.default_requires_approval = true;
177            }
178            _ => {}
179        }
180        ToolCallClassification::from_metadata(&metadata)
181    }
182
183    async fn execute(&self, args: Value, _ctx: ToolExecutionContext) -> ToolResult {
184        let input: FileInput = match serde_json::from_value(args) {
185            Ok(input) => input,
186            Err(e) => return ToolResult::error(format!("Invalid input: {}", e)),
187        };
188
189        match input.operation.to_lowercase().as_str() {
190            "read" => self.handle_read(&input),
191            "write" => self.handle_write(&input),
192            "append" => self.handle_append(&input),
193            "exists" => self.handle_exists(&input),
194            "delete" => self.handle_delete(&input),
195            "list" => self.handle_list(&input),
196            "mkdir" => self.handle_mkdir(&input),
197            "info" => self.handle_info(&input),
198            _ => ToolResult::error(format!(
199                "Unknown operation: {}. Valid: read, write, append, exists, delete, list, mkdir, info",
200                input.operation
201            )),
202        }
203    }
204}
205
206impl FileTool {
207    fn handle_read(&self, input: &FileInput) -> ToolResult {
208        if let Err(error) = self.validate_path(&input.path) {
209            return ToolResult::error(error);
210        }
211        match fs::read_to_string(&input.path) {
212            Ok(content) => {
213                let output = ReadOutput {
214                    size: content.len(),
215                    content,
216                    path: input.path.clone(),
217                };
218                self.to_result(&output)
219            }
220            Err(e) => ToolResult::error(format!("Read error: {}", e)),
221        }
222    }
223
224    fn handle_write(&self, input: &FileInput) -> ToolResult {
225        if let Err(error) = self.validate_path(&input.path) {
226            return ToolResult::error(error);
227        }
228        let content = input.content.as_deref().unwrap_or("");
229        match fs::write(&input.path, content) {
230            Ok(_) => {
231                let output = WriteOutput {
232                    success: true,
233                    path: input.path.clone(),
234                    bytes_written: content.len(),
235                };
236                self.to_result(&output)
237            }
238            Err(e) => ToolResult::error(format!("Write error: {}", e)),
239        }
240    }
241
242    fn handle_append(&self, input: &FileInput) -> ToolResult {
243        use std::fs::OpenOptions;
244        use std::io::Write;
245
246        if let Err(error) = self.validate_path(&input.path) {
247            return ToolResult::error(error);
248        }
249        let content = input.content.as_deref().unwrap_or("");
250        let file = OpenOptions::new()
251            .create(true)
252            .append(true)
253            .open(&input.path);
254
255        match file {
256            Ok(mut f) => match f.write_all(content.as_bytes()) {
257                Ok(_) => {
258                    let output = WriteOutput {
259                        success: true,
260                        path: input.path.clone(),
261                        bytes_written: content.len(),
262                    };
263                    self.to_result(&output)
264                }
265                Err(e) => ToolResult::error(format!("Append error: {}", e)),
266            },
267            Err(e) => ToolResult::error(format!("File open error: {}", e)),
268        }
269    }
270
271    fn handle_exists(&self, input: &FileInput) -> ToolResult {
272        if let Err(error) = self.validate_path(&input.path) {
273            return ToolResult::error(error);
274        }
275        let path = Path::new(&input.path);
276        let output = ExistsOutput {
277            exists: path.exists(),
278            path: input.path.clone(),
279            is_file: path.is_file(),
280            is_dir: path.is_dir(),
281        };
282        self.to_result(&output)
283    }
284
285    fn handle_delete(&self, input: &FileInput) -> ToolResult {
286        if let Err(error) = self.validate_path(&input.path) {
287            return ToolResult::error(error);
288        }
289        let path = Path::new(&input.path);
290        let result = if path.is_dir() {
291            fs::remove_dir_all(path)
292        } else {
293            fs::remove_file(path)
294        };
295
296        match result {
297            Ok(_) => {
298                let output = DeleteOutput {
299                    success: true,
300                    path: input.path.clone(),
301                };
302                self.to_result(&output)
303            }
304            Err(e) => ToolResult::error(format!("Delete error: {}", e)),
305        }
306    }
307
308    fn handle_list(&self, input: &FileInput) -> ToolResult {
309        if let Err(error) = self.validate_path(&input.path) {
310            return ToolResult::error(error);
311        }
312        let path = Path::new(&input.path);
313        if !path.is_dir() {
314            return ToolResult::error(format!("Not a directory: {}", input.path));
315        }
316
317        let pattern = input.pattern.as_deref();
318
319        match fs::read_dir(path) {
320            Ok(entries) => {
321                let mut list_entries = Vec::new();
322
323                for entry in entries.flatten() {
324                    let file_name = entry.file_name().to_string_lossy().to_string();
325
326                    if let Some(pat) = pattern {
327                        if !self.matches_pattern(&file_name, pat) {
328                            continue;
329                        }
330                    }
331
332                    let metadata = entry.metadata().ok();
333                    let entry_path = entry.path();
334
335                    list_entries.push(ListEntry {
336                        name: file_name,
337                        path: entry_path.to_string_lossy().to_string(),
338                        is_file: entry_path.is_file(),
339                        is_dir: entry_path.is_dir(),
340                        size: metadata.map(|m| m.len()),
341                    });
342                }
343
344                let output = ListOutput {
345                    count: list_entries.len(),
346                    entries: list_entries,
347                    path: input.path.clone(),
348                };
349                self.to_result(&output)
350            }
351            Err(e) => ToolResult::error(format!("List error: {}", e)),
352        }
353    }
354
355    fn handle_mkdir(&self, input: &FileInput) -> ToolResult {
356        if let Err(error) = self.validate_path(&input.path) {
357            return ToolResult::error(error);
358        }
359        match fs::create_dir_all(&input.path) {
360            Ok(_) => {
361                let output = MkdirOutput {
362                    success: true,
363                    path: input.path.clone(),
364                };
365                self.to_result(&output)
366            }
367            Err(e) => ToolResult::error(format!("Mkdir error: {}", e)),
368        }
369    }
370
371    fn handle_info(&self, input: &FileInput) -> ToolResult {
372        if let Err(error) = self.validate_path(&input.path) {
373            return ToolResult::error(error);
374        }
375        let path = Path::new(&input.path);
376
377        if !path.exists() {
378            let output = InfoOutput {
379                path: input.path.clone(),
380                exists: false,
381                is_file: false,
382                is_dir: false,
383                size: None,
384                modified: None,
385                created: None,
386            };
387            return self.to_result(&output);
388        }
389
390        let metadata = match fs::metadata(path) {
391            Ok(m) => m,
392            Err(e) => return ToolResult::error(format!("Metadata error: {}", e)),
393        };
394
395        let modified = metadata.modified().ok().map(|t| {
396            let datetime: chrono::DateTime<chrono::Utc> = t.into();
397            datetime.to_rfc3339()
398        });
399
400        let created = metadata.created().ok().map(|t| {
401            let datetime: chrono::DateTime<chrono::Utc> = t.into();
402            datetime.to_rfc3339()
403        });
404
405        let output = InfoOutput {
406            path: input.path.clone(),
407            exists: true,
408            is_file: metadata.is_file(),
409            is_dir: metadata.is_dir(),
410            size: Some(metadata.len()),
411            modified,
412            created,
413        };
414        self.to_result(&output)
415    }
416
417    fn validate_path(&self, path: &str) -> Result<(), String> {
418        let blocked = Path::new(path).components().any(|component| {
419            matches!(component, std::path::Component::Normal(value) if value.to_string_lossy() == ".git")
420        });
421        if blocked {
422            Err(
423                "Access to raw .git paths is blocked. Use git_status or git_diff instead."
424                    .to_string(),
425            )
426        } else {
427            Ok(())
428        }
429    }
430
431    fn matches_pattern(&self, name: &str, pattern: &str) -> bool {
432        let pattern = pattern.trim();
433        if pattern.is_empty() || pattern == "*" {
434            return true;
435        }
436
437        if pattern.starts_with("*.") {
438            let ext = &pattern[2..];
439            return name.ends_with(&format!(".{}", ext));
440        }
441
442        if pattern.ends_with(".*") {
443            let prefix = &pattern[..pattern.len() - 2];
444            return name.starts_with(prefix);
445        }
446
447        if pattern.starts_with('*') && pattern.ends_with('*') {
448            let middle = &pattern[1..pattern.len() - 1];
449            return name.contains(middle);
450        }
451
452        name == pattern
453    }
454
455    fn to_result<T: Serialize>(&self, output: &T) -> ToolResult {
456        match serde_json::to_string(output) {
457            Ok(json) => ToolResult::ok(json),
458            Err(e) => ToolResult::error(format!("Serialization error: {}", e)),
459        }
460    }
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466    use std::fs;
467    use tempfile::tempdir;
468
469    #[tokio::test]
470    async fn test_write_and_read() {
471        let dir = tempdir().unwrap();
472        let file_path = dir.path().join("test.txt");
473        let path_str = file_path.to_str().unwrap();
474        let tool = FileTool::new();
475
476        let result = tool
477            .execute(
478                serde_json::json!({
479                    "operation": "write",
480                    "path": path_str,
481                    "content": "hello world"
482                }),
483                ai_agents_core::ToolExecutionContext::test("test"),
484            )
485            .await;
486        assert!(result.success);
487
488        let result = tool
489            .execute(
490                serde_json::json!({
491                    "operation": "read",
492                    "path": path_str
493                }),
494                ai_agents_core::ToolExecutionContext::test("test"),
495            )
496            .await;
497        assert!(result.success);
498        assert!(result.output.contains("hello world"));
499    }
500
501    #[tokio::test]
502    async fn test_append() {
503        let dir = tempdir().unwrap();
504        let file_path = dir.path().join("append.txt");
505        let path_str = file_path.to_str().unwrap();
506        let tool = FileTool::new();
507
508        tool.execute(
509            serde_json::json!({
510                "operation": "write",
511                "path": path_str,
512                "content": "line1\n"
513            }),
514            ai_agents_core::ToolExecutionContext::test("test"),
515        )
516        .await;
517
518        tool.execute(
519            serde_json::json!({
520                "operation": "append",
521                "path": path_str,
522                "content": "line2\n"
523            }),
524            ai_agents_core::ToolExecutionContext::test("test"),
525        )
526        .await;
527
528        let content = fs::read_to_string(&file_path).unwrap();
529        assert!(content.contains("line1"));
530        assert!(content.contains("line2"));
531    }
532
533    #[tokio::test]
534    async fn test_exists() {
535        let dir = tempdir().unwrap();
536        let file_path = dir.path().join("exists.txt");
537        let path_str = file_path.to_str().unwrap();
538        let tool = FileTool::new();
539
540        let result = tool
541            .execute(
542                serde_json::json!({
543                    "operation": "exists",
544                    "path": path_str
545                }),
546                ai_agents_core::ToolExecutionContext::test("test"),
547            )
548            .await;
549        assert!(result.success);
550        assert!(result.output.contains("\"exists\":false"));
551
552        fs::write(&file_path, "test").unwrap();
553
554        let result = tool
555            .execute(
556                serde_json::json!({
557                    "operation": "exists",
558                    "path": path_str
559                }),
560                ai_agents_core::ToolExecutionContext::test("test"),
561            )
562            .await;
563        assert!(result.success);
564        assert!(result.output.contains("\"exists\":true"));
565    }
566
567    #[tokio::test]
568    async fn test_delete() {
569        let dir = tempdir().unwrap();
570        let file_path = dir.path().join("delete.txt");
571        let path_str = file_path.to_str().unwrap();
572        let tool = FileTool::new();
573
574        fs::write(&file_path, "test").unwrap();
575        assert!(file_path.exists());
576
577        let result = tool
578            .execute(
579                serde_json::json!({
580                    "operation": "delete",
581                    "path": path_str
582                }),
583                ai_agents_core::ToolExecutionContext::test("test"),
584            )
585            .await;
586        assert!(result.success);
587        assert!(!file_path.exists());
588    }
589
590    #[tokio::test]
591    async fn test_list() {
592        let dir = tempdir().unwrap();
593        let tool = FileTool::new();
594
595        fs::write(dir.path().join("a.txt"), "a").unwrap();
596        fs::write(dir.path().join("b.json"), "b").unwrap();
597        fs::write(dir.path().join("c.txt"), "c").unwrap();
598
599        let result = tool
600            .execute(
601                serde_json::json!({
602                    "operation": "list",
603                    "path": dir.path().to_str().unwrap()
604                }),
605                ai_agents_core::ToolExecutionContext::test("test"),
606            )
607            .await;
608        assert!(result.success);
609        assert!(result.output.contains("\"count\":3"));
610
611        let result = tool
612            .execute(
613                serde_json::json!({
614                    "operation": "list",
615                    "path": dir.path().to_str().unwrap(),
616                    "pattern": "*.txt"
617                }),
618                ai_agents_core::ToolExecutionContext::test("test"),
619            )
620            .await;
621        assert!(result.success);
622        assert!(result.output.contains("\"count\":2"));
623    }
624
625    #[tokio::test]
626    async fn test_mkdir() {
627        let dir = tempdir().unwrap();
628        let new_dir = dir.path().join("new/nested/dir");
629        let tool = FileTool::new();
630
631        let result = tool
632            .execute(
633                serde_json::json!({
634                    "operation": "mkdir",
635                    "path": new_dir.to_str().unwrap()
636                }),
637                ai_agents_core::ToolExecutionContext::test("test"),
638            )
639            .await;
640        assert!(result.success);
641        assert!(new_dir.exists());
642    }
643
644    #[tokio::test]
645    async fn test_info() {
646        let dir = tempdir().unwrap();
647        let file_path = dir.path().join("info.txt");
648        let tool = FileTool::new();
649
650        fs::write(&file_path, "test content").unwrap();
651
652        let result = tool
653            .execute(
654                serde_json::json!({
655                    "operation": "info",
656                    "path": file_path.to_str().unwrap()
657                }),
658                ai_agents_core::ToolExecutionContext::test("test"),
659            )
660            .await;
661        assert!(result.success);
662        assert!(result.output.contains("\"is_file\":true"));
663        assert!(result.output.contains("\"size\":12"));
664    }
665
666    #[tokio::test]
667    async fn test_invalid_operation() {
668        let tool = FileTool::new();
669        let result = tool
670            .execute(
671                serde_json::json!({
672                    "operation": "invalid",
673                    "path": "/tmp/test"
674                }),
675                ai_agents_core::ToolExecutionContext::test("test"),
676            )
677            .await;
678        assert!(!result.success);
679    }
680
681    #[tokio::test]
682    async fn test_git_paths_are_blocked() {
683        let tool = FileTool::new();
684        let result = tool
685            .execute(
686                serde_json::json!({
687                    "operation": "read",
688                    "path": ".git/config"
689                }),
690                ai_agents_core::ToolExecutionContext::test("test"),
691            )
692            .await;
693        assert!(!result.success);
694        assert!(result.output.contains("git_status") || result.output.contains("git_diff"));
695    }
696}