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                        && !self.matches_pattern(&file_name, pat)
328                    {
329                        continue;
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 let Some(ext) = pattern.strip_prefix("*.") {
438            return name.ends_with(&format!(".{}", ext));
439        }
440
441        if let Some(prefix) = pattern.strip_suffix(".*") {
442            return name.starts_with(prefix);
443        }
444
445        if pattern.starts_with('*') && pattern.ends_with('*') {
446            let middle = &pattern[1..pattern.len() - 1];
447            return name.contains(middle);
448        }
449
450        name == pattern
451    }
452
453    fn to_result<T: Serialize>(&self, output: &T) -> ToolResult {
454        match serde_json::to_string(output) {
455            Ok(json) => ToolResult::ok(json),
456            Err(e) => ToolResult::error(format!("Serialization error: {}", e)),
457        }
458    }
459}
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464    use std::fs;
465    use tempfile::tempdir;
466
467    #[tokio::test]
468    async fn test_write_and_read() {
469        let dir = tempdir().unwrap();
470        let file_path = dir.path().join("test.txt");
471        let path_str = file_path.to_str().unwrap();
472        let tool = FileTool::new();
473
474        let result = tool
475            .execute(
476                serde_json::json!({
477                    "operation": "write",
478                    "path": path_str,
479                    "content": "hello world"
480                }),
481                ai_agents_core::ToolExecutionContext::test("test"),
482            )
483            .await;
484        assert!(result.success);
485
486        let result = tool
487            .execute(
488                serde_json::json!({
489                    "operation": "read",
490                    "path": path_str
491                }),
492                ai_agents_core::ToolExecutionContext::test("test"),
493            )
494            .await;
495        assert!(result.success);
496        assert!(result.output.contains("hello world"));
497    }
498
499    #[tokio::test]
500    async fn test_append() {
501        let dir = tempdir().unwrap();
502        let file_path = dir.path().join("append.txt");
503        let path_str = file_path.to_str().unwrap();
504        let tool = FileTool::new();
505
506        tool.execute(
507            serde_json::json!({
508                "operation": "write",
509                "path": path_str,
510                "content": "line1\n"
511            }),
512            ai_agents_core::ToolExecutionContext::test("test"),
513        )
514        .await;
515
516        tool.execute(
517            serde_json::json!({
518                "operation": "append",
519                "path": path_str,
520                "content": "line2\n"
521            }),
522            ai_agents_core::ToolExecutionContext::test("test"),
523        )
524        .await;
525
526        let content = fs::read_to_string(&file_path).unwrap();
527        assert!(content.contains("line1"));
528        assert!(content.contains("line2"));
529    }
530
531    #[tokio::test]
532    async fn test_exists() {
533        let dir = tempdir().unwrap();
534        let file_path = dir.path().join("exists.txt");
535        let path_str = file_path.to_str().unwrap();
536        let tool = FileTool::new();
537
538        let result = tool
539            .execute(
540                serde_json::json!({
541                    "operation": "exists",
542                    "path": path_str
543                }),
544                ai_agents_core::ToolExecutionContext::test("test"),
545            )
546            .await;
547        assert!(result.success);
548        assert!(result.output.contains("\"exists\":false"));
549
550        fs::write(&file_path, "test").unwrap();
551
552        let result = tool
553            .execute(
554                serde_json::json!({
555                    "operation": "exists",
556                    "path": path_str
557                }),
558                ai_agents_core::ToolExecutionContext::test("test"),
559            )
560            .await;
561        assert!(result.success);
562        assert!(result.output.contains("\"exists\":true"));
563    }
564
565    #[tokio::test]
566    async fn test_delete() {
567        let dir = tempdir().unwrap();
568        let file_path = dir.path().join("delete.txt");
569        let path_str = file_path.to_str().unwrap();
570        let tool = FileTool::new();
571
572        fs::write(&file_path, "test").unwrap();
573        assert!(file_path.exists());
574
575        let result = tool
576            .execute(
577                serde_json::json!({
578                    "operation": "delete",
579                    "path": path_str
580                }),
581                ai_agents_core::ToolExecutionContext::test("test"),
582            )
583            .await;
584        assert!(result.success);
585        assert!(!file_path.exists());
586    }
587
588    #[tokio::test]
589    async fn test_list() {
590        let dir = tempdir().unwrap();
591        let tool = FileTool::new();
592
593        fs::write(dir.path().join("a.txt"), "a").unwrap();
594        fs::write(dir.path().join("b.json"), "b").unwrap();
595        fs::write(dir.path().join("c.txt"), "c").unwrap();
596
597        let result = tool
598            .execute(
599                serde_json::json!({
600                    "operation": "list",
601                    "path": dir.path().to_str().unwrap()
602                }),
603                ai_agents_core::ToolExecutionContext::test("test"),
604            )
605            .await;
606        assert!(result.success);
607        assert!(result.output.contains("\"count\":3"));
608
609        let result = tool
610            .execute(
611                serde_json::json!({
612                    "operation": "list",
613                    "path": dir.path().to_str().unwrap(),
614                    "pattern": "*.txt"
615                }),
616                ai_agents_core::ToolExecutionContext::test("test"),
617            )
618            .await;
619        assert!(result.success);
620        assert!(result.output.contains("\"count\":2"));
621    }
622
623    #[tokio::test]
624    async fn test_mkdir() {
625        let dir = tempdir().unwrap();
626        let new_dir = dir.path().join("new/nested/dir");
627        let tool = FileTool::new();
628
629        let result = tool
630            .execute(
631                serde_json::json!({
632                    "operation": "mkdir",
633                    "path": new_dir.to_str().unwrap()
634                }),
635                ai_agents_core::ToolExecutionContext::test("test"),
636            )
637            .await;
638        assert!(result.success);
639        assert!(new_dir.exists());
640    }
641
642    #[tokio::test]
643    async fn test_info() {
644        let dir = tempdir().unwrap();
645        let file_path = dir.path().join("info.txt");
646        let tool = FileTool::new();
647
648        fs::write(&file_path, "test content").unwrap();
649
650        let result = tool
651            .execute(
652                serde_json::json!({
653                    "operation": "info",
654                    "path": file_path.to_str().unwrap()
655                }),
656                ai_agents_core::ToolExecutionContext::test("test"),
657            )
658            .await;
659        assert!(result.success);
660        assert!(result.output.contains("\"is_file\":true"));
661        assert!(result.output.contains("\"size\":12"));
662    }
663
664    #[tokio::test]
665    async fn test_invalid_operation() {
666        let tool = FileTool::new();
667        let result = tool
668            .execute(
669                serde_json::json!({
670                    "operation": "invalid",
671                    "path": "/tmp/test"
672                }),
673                ai_agents_core::ToolExecutionContext::test("test"),
674            )
675            .await;
676        assert!(!result.success);
677    }
678
679    #[tokio::test]
680    async fn test_git_paths_are_blocked() {
681        let tool = FileTool::new();
682        let result = tool
683            .execute(
684                serde_json::json!({
685                    "operation": "read",
686                    "path": ".git/config"
687                }),
688                ai_agents_core::ToolExecutionContext::test("test"),
689            )
690            .await;
691        assert!(!result.success);
692        assert!(result.output.contains("git_status") || result.output.contains("git_diff"));
693    }
694}