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 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}