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: String,
32 path: String,
34 #[serde(default)]
36 content: Option<String>,
37 #[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}