1use std::collections::HashMap;
8use std::future::Future;
9use std::path::Path;
10use std::pin::Pin;
11use std::sync::Arc;
12
13use tokio::fs;
14
15use super::types::{
16 DisplayConfig, DisplayResult, Executable, ResultContentType, ToolContext, ToolType,
17};
18use crate::permissions::{GrantTarget, PermissionLevel, PermissionRegistry, PermissionRequest};
19
20pub const WRITE_FILE_TOOL_NAME: &str = "write_file";
22
23pub const WRITE_FILE_TOOL_DESCRIPTION: &str = r#"Writes content to a file, creating it if it doesn't exist or overwriting if it does.
25
26Usage:
27- The file_path parameter must be an absolute path, not a relative path
28- This tool will overwrite the existing file if there is one at the provided path
29- Parent directories will be created automatically if they don't exist
30- Requires user permission before writing (may be cached for session)
31
32Returns:
33- Success message with bytes written on successful write
34- Error message if permission is denied or the operation fails"#;
35
36pub const WRITE_FILE_TOOL_SCHEMA: &str = r#"{
38 "type": "object",
39 "properties": {
40 "file_path": {
41 "type": "string",
42 "description": "The absolute path to the file to write"
43 },
44 "content": {
45 "type": "string",
46 "description": "The content to write to the file"
47 },
48 "create_directories": {
49 "type": "boolean",
50 "description": "Whether to create parent directories if they don't exist. Defaults to true."
51 }
52 },
53 "required": ["file_path", "content"]
54}"#;
55
56pub struct WriteFileTool {
58 permission_registry: Arc<PermissionRegistry>,
60}
61
62impl WriteFileTool {
63 pub fn new(permission_registry: Arc<PermissionRegistry>) -> Self {
68 Self {
69 permission_registry,
70 }
71 }
72
73 fn build_permission_request(
82 tool_use_id: &str,
83 file_path: &str,
84 content_len: usize,
85 is_overwrite: bool,
86 will_create_directories: bool,
87 ) -> PermissionRequest {
88 let action_verb = if is_overwrite { "Overwrite" } else { "Create" };
89 let dir_note = if will_create_directories {
90 " (will create parent directories)"
91 } else {
92 ""
93 };
94 let reason = format!(
95 "{} file with {} bytes of content{}",
96 action_verb.to_lowercase(),
97 content_len,
98 dir_note
99 );
100
101 PermissionRequest::new(
102 tool_use_id,
103 GrantTarget::path(file_path, false),
104 PermissionLevel::Write,
105 format!("Write file: {}", file_path),
106 )
107 .with_reason(reason)
108 .with_tool(WRITE_FILE_TOOL_NAME)
109 }
110}
111
112impl Executable for WriteFileTool {
113 fn name(&self) -> &str {
114 WRITE_FILE_TOOL_NAME
115 }
116
117 fn description(&self) -> &str {
118 WRITE_FILE_TOOL_DESCRIPTION
119 }
120
121 fn input_schema(&self) -> &str {
122 WRITE_FILE_TOOL_SCHEMA
123 }
124
125 fn tool_type(&self) -> ToolType {
126 ToolType::TextEdit
127 }
128
129 fn execute(
130 &self,
131 context: ToolContext,
132 input: HashMap<String, serde_json::Value>,
133 ) -> Pin<Box<dyn Future<Output = Result<String, String>> + Send>> {
134 let permission_registry = self.permission_registry.clone();
135
136 Box::pin(async move {
137 let file_path = input
141 .get("file_path")
142 .and_then(|v| v.as_str())
143 .ok_or_else(|| "Missing required 'file_path' parameter".to_string())?;
144
145 let content = input
146 .get("content")
147 .and_then(|v| v.as_str())
148 .ok_or_else(|| "Missing required 'content' parameter".to_string())?;
149
150 let create_directories = input
151 .get("create_directories")
152 .and_then(|v| v.as_bool())
153 .unwrap_or(true);
154
155 let path = Path::new(file_path);
156
157 if !path.is_absolute() {
159 return Err(format!(
160 "file_path must be an absolute path, got: {}",
161 file_path
162 ));
163 }
164
165 let is_overwrite = path.exists();
167
168 let will_create_directories =
172 create_directories && path.parent().map(|p| !p.exists()).unwrap_or(false);
173
174 if !context.permissions_pre_approved {
178 let permission_request = Self::build_permission_request(
179 &context.tool_use_id,
180 file_path,
181 content.len(),
182 is_overwrite,
183 will_create_directories,
184 );
185
186 let response_rx = permission_registry
187 .request_permission(
188 context.session_id,
189 permission_request,
190 context.turn_id.clone(),
191 )
192 .await
193 .map_err(|e| format!("Failed to request permission: {}", e))?;
194
195 let response = response_rx
196 .await
197 .map_err(|_| "Permission request was cancelled".to_string())?;
198
199 if !response.granted {
200 let reason = response
201 .message
202 .unwrap_or_else(|| "Permission denied by user".to_string());
203 return Err(format!(
204 "Permission denied to write '{}': {}",
205 file_path, reason
206 ));
207 }
208 }
209
210 if create_directories
214 && let Some(parent) = path.parent()
215 && !parent.exists()
216 {
217 fs::create_dir_all(parent)
218 .await
219 .map_err(|e| format!("Failed to create parent directories: {}", e))?;
220 }
221
222 let bytes_written = content.len();
226 fs::write(path, content)
227 .await
228 .map_err(|e| format!("Failed to write file '{}': {}", file_path, e))?;
229
230 let action = if is_overwrite { "overwrote" } else { "created" };
231 Ok(format!(
232 "Successfully {} '{}' ({} bytes)",
233 action, file_path, bytes_written
234 ))
235 })
236 }
237
238 fn display_config(&self) -> DisplayConfig {
239 DisplayConfig {
240 display_name: "Write File".to_string(),
241 display_title: Box::new(|input| {
242 input
243 .get("file_path")
244 .and_then(|v| v.as_str())
245 .map(|p| {
246 Path::new(p)
247 .file_name()
248 .and_then(|n| n.to_str())
249 .unwrap_or(p)
250 .to_string()
251 })
252 .unwrap_or_default()
253 }),
254 display_content: Box::new(|input, result| {
255 let content_preview = input
256 .get("content")
257 .and_then(|v| v.as_str())
258 .map(|c| {
259 let lines: Vec<&str> = c.lines().take(10).collect();
260 if c.lines().count() > 10 {
261 format!("{}...\n[truncated]", lines.join("\n"))
262 } else {
263 lines.join("\n")
264 }
265 })
266 .unwrap_or_else(|| result.to_string());
267
268 DisplayResult {
269 content: content_preview,
270 content_type: ResultContentType::PlainText,
271 is_truncated: input
272 .get("content")
273 .and_then(|v| v.as_str())
274 .map(|c| c.lines().count() > 10)
275 .unwrap_or(false),
276 full_length: input
277 .get("content")
278 .and_then(|v| v.as_str())
279 .map(|c| c.lines().count())
280 .unwrap_or(0),
281 }
282 }),
283 }
284 }
285
286 fn compact_summary(&self, input: &HashMap<String, serde_json::Value>, _result: &str) -> String {
287 let filename = input
288 .get("file_path")
289 .and_then(|v| v.as_str())
290 .map(|p| {
291 Path::new(p)
292 .file_name()
293 .and_then(|n| n.to_str())
294 .unwrap_or(p)
295 })
296 .unwrap_or("unknown");
297
298 let bytes = input
299 .get("content")
300 .and_then(|v| v.as_str())
301 .map(|c| c.len())
302 .unwrap_or(0);
303
304 format!("[WriteFile: {} ({} bytes)]", filename, bytes)
305 }
306
307 fn required_permissions(
308 &self,
309 context: &ToolContext,
310 input: &HashMap<String, serde_json::Value>,
311 ) -> Option<Vec<PermissionRequest>> {
312 let file_path = input.get("file_path").and_then(|v| v.as_str())?;
314
315 let content = input.get("content").and_then(|v| v.as_str())?;
317
318 let path = Path::new(file_path);
319
320 if !path.is_absolute() {
322 return None;
323 }
324
325 let is_overwrite = path.exists();
327
328 let create_directories = input
330 .get("create_directories")
331 .and_then(|v| v.as_bool())
332 .unwrap_or(true);
333 let will_create_directories =
334 create_directories && path.parent().map(|p| !p.exists()).unwrap_or(false);
335
336 let permission_request = Self::build_permission_request(
338 &context.tool_use_id,
339 file_path,
340 content.len(),
341 is_overwrite,
342 will_create_directories,
343 );
344
345 Some(vec![permission_request])
346 }
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352 use crate::controller::PermissionPanelResponse;
353 use crate::controller::types::ControllerEvent;
354 use crate::permissions::PermissionLevel;
355 use tempfile::TempDir;
356 use tokio::sync::mpsc;
357
358 fn create_test_registry() -> (Arc<PermissionRegistry>, mpsc::Receiver<ControllerEvent>) {
360 let (tx, rx) = mpsc::channel(16);
361 let registry = Arc::new(PermissionRegistry::new(tx));
362 (registry, rx)
363 }
364
365 fn grant_once() -> PermissionPanelResponse {
366 PermissionPanelResponse {
367 granted: true,
368 grant: None,
369 message: None,
370 }
371 }
372
373 fn deny(reason: &str) -> PermissionPanelResponse {
374 PermissionPanelResponse {
375 granted: false,
376 grant: None,
377 message: Some(reason.to_string()),
378 }
379 }
380
381 #[tokio::test]
382 async fn test_write_new_file_with_permission_granted() {
383 let (registry, mut event_rx) = create_test_registry();
384 let tool = WriteFileTool::new(registry.clone());
385 let temp_dir = TempDir::new().unwrap();
386 let file_path = temp_dir.path().join("test.txt");
387
388 let mut input = HashMap::new();
389 input.insert(
390 "file_path".to_string(),
391 serde_json::Value::String(file_path.to_str().unwrap().to_string()),
392 );
393 input.insert(
394 "content".to_string(),
395 serde_json::Value::String("Hello, World!".to_string()),
396 );
397
398 let context = ToolContext {
399 session_id: 1,
400 tool_use_id: "test-123".to_string(),
401 turn_id: None,
402 permissions_pre_approved: false,
403 };
404
405 let registry_clone = registry.clone();
407 tokio::spawn(async move {
408 if let Some(ControllerEvent::PermissionRequired { tool_use_id, .. }) =
410 event_rx.recv().await
411 {
412 registry_clone
414 .respond_to_request(&tool_use_id, grant_once())
415 .await
416 .unwrap();
417 }
418 });
419
420 let result = tool.execute(context, input).await;
421
422 assert!(result.is_ok());
423 assert!(file_path.exists());
424 assert_eq!(
425 tokio::fs::read_to_string(&file_path).await.unwrap(),
426 "Hello, World!"
427 );
428 }
429
430 #[tokio::test]
431 async fn test_write_file_permission_denied() {
432 let (registry, mut event_rx) = create_test_registry();
433 let tool = WriteFileTool::new(registry.clone());
434 let temp_dir = TempDir::new().unwrap();
435 let file_path = temp_dir.path().join("test.txt");
436
437 let mut input = HashMap::new();
438 input.insert(
439 "file_path".to_string(),
440 serde_json::Value::String(file_path.to_str().unwrap().to_string()),
441 );
442 input.insert(
443 "content".to_string(),
444 serde_json::Value::String("Hello, World!".to_string()),
445 );
446
447 let context = ToolContext {
448 session_id: 1,
449 tool_use_id: "test-456".to_string(),
450 turn_id: None,
451 permissions_pre_approved: false,
452 };
453
454 let registry_clone = registry.clone();
456 tokio::spawn(async move {
457 if let Some(ControllerEvent::PermissionRequired { tool_use_id, .. }) =
458 event_rx.recv().await
459 {
460 registry_clone
462 .respond_to_request(&tool_use_id, deny("Not allowed"))
463 .await
464 .unwrap();
465 }
466 });
467
468 let result = tool.execute(context, input).await;
469
470 assert!(result.is_err());
471 assert!(result.unwrap_err().contains("Permission denied"));
472 assert!(!file_path.exists());
473 }
474
475 #[tokio::test]
476 async fn test_write_file_session_permission_cached() {
477 let (registry, mut event_rx) = create_test_registry();
478 let tool = WriteFileTool::new(registry.clone());
479 let temp_dir = TempDir::new().unwrap();
480
481 let file_path_1 = temp_dir.path().join("test1.txt");
483 let mut input_1 = HashMap::new();
484 input_1.insert(
485 "file_path".to_string(),
486 serde_json::Value::String(file_path_1.to_str().unwrap().to_string()),
487 );
488 input_1.insert(
489 "content".to_string(),
490 serde_json::Value::String("Content 1".to_string()),
491 );
492
493 let context_1 = ToolContext {
494 session_id: 1,
495 tool_use_id: "test-1".to_string(),
496 turn_id: None,
497 permissions_pre_approved: false,
498 };
499
500 let registry_clone = registry.clone();
502 tokio::spawn(async move {
503 if let Some(ControllerEvent::PermissionRequired { tool_use_id, .. }) =
504 event_rx.recv().await
505 {
506 registry_clone
507 .respond_to_request(&tool_use_id, grant_once())
508 .await
509 .unwrap();
510 }
511 });
512
513 let result_1 = tool.execute(context_1, input_1).await;
514 assert!(result_1.is_ok());
515 assert!(file_path_1.exists());
516
517 }
522
523 #[tokio::test]
524 async fn test_overwrite_existing_file() {
525 let (registry, mut event_rx) = create_test_registry();
526 let tool = WriteFileTool::new(registry.clone());
527 let temp_dir = TempDir::new().unwrap();
528 let file_path = temp_dir.path().join("existing.txt");
529
530 tokio::fs::write(&file_path, "old content").await.unwrap();
532
533 let mut input = HashMap::new();
534 input.insert(
535 "file_path".to_string(),
536 serde_json::Value::String(file_path.to_str().unwrap().to_string()),
537 );
538 input.insert(
539 "content".to_string(),
540 serde_json::Value::String("new content".to_string()),
541 );
542
543 let context = ToolContext {
544 session_id: 1,
545 tool_use_id: "test-overwrite".to_string(),
546 turn_id: None,
547 permissions_pre_approved: false,
548 };
549
550 let registry_clone = registry.clone();
552 tokio::spawn(async move {
553 if let Some(ControllerEvent::PermissionRequired { tool_use_id, .. }) =
554 event_rx.recv().await
555 {
556 registry_clone
557 .respond_to_request(&tool_use_id, grant_once())
558 .await
559 .unwrap();
560 }
561 });
562
563 let result = tool.execute(context, input).await;
564
565 assert!(result.is_ok());
566 assert!(result.unwrap().contains("overwrote"));
567 assert_eq!(
568 tokio::fs::read_to_string(&file_path).await.unwrap(),
569 "new content"
570 );
571 }
572
573 #[tokio::test]
574 async fn test_create_parent_directories() {
575 let (registry, mut event_rx) = create_test_registry();
576 let tool = WriteFileTool::new(registry.clone());
577 let temp_dir = TempDir::new().unwrap();
578 let file_path = temp_dir.path().join("nested/dir/test.txt");
579
580 let mut input = HashMap::new();
581 input.insert(
582 "file_path".to_string(),
583 serde_json::Value::String(file_path.to_str().unwrap().to_string()),
584 );
585 input.insert(
586 "content".to_string(),
587 serde_json::Value::String("nested content".to_string()),
588 );
589
590 let context = ToolContext {
591 session_id: 1,
592 tool_use_id: "test-nested".to_string(),
593 turn_id: None,
594 permissions_pre_approved: false,
595 };
596
597 let registry_clone = registry.clone();
599 tokio::spawn(async move {
600 if let Some(ControllerEvent::PermissionRequired { tool_use_id, .. }) =
601 event_rx.recv().await
602 {
603 registry_clone
604 .respond_to_request(&tool_use_id, grant_once())
605 .await
606 .unwrap();
607 }
608 });
609
610 let result = tool.execute(context, input).await;
611
612 assert!(result.is_ok());
613 assert!(file_path.exists());
614 assert!(file_path.parent().unwrap().exists());
615 }
616
617 #[tokio::test]
618 async fn test_relative_path_rejected() {
619 let (registry, _event_rx) = create_test_registry();
620 let tool = WriteFileTool::new(registry);
621
622 let mut input = HashMap::new();
623 input.insert(
624 "file_path".to_string(),
625 serde_json::Value::String("relative/path.txt".to_string()),
626 );
627 input.insert(
628 "content".to_string(),
629 serde_json::Value::String("content".to_string()),
630 );
631
632 let context = ToolContext {
633 session_id: 1,
634 tool_use_id: "test".to_string(),
635 turn_id: None,
636 permissions_pre_approved: false,
637 };
638
639 let result = tool.execute(context, input).await;
640 assert!(result.is_err());
641 assert!(result.unwrap_err().contains("absolute path"));
642 }
643
644 #[tokio::test]
645 async fn test_missing_file_path() {
646 let (registry, _event_rx) = create_test_registry();
647 let tool = WriteFileTool::new(registry);
648
649 let mut input = HashMap::new();
650 input.insert(
651 "content".to_string(),
652 serde_json::Value::String("content".to_string()),
653 );
654
655 let context = ToolContext {
656 session_id: 1,
657 tool_use_id: "test".to_string(),
658 turn_id: None,
659 permissions_pre_approved: false,
660 };
661
662 let result = tool.execute(context, input).await;
663 assert!(result.is_err());
664 assert!(result.unwrap_err().contains("Missing required 'file_path'"));
665 }
666
667 #[tokio::test]
668 async fn test_missing_content() {
669 let (registry, _event_rx) = create_test_registry();
670 let tool = WriteFileTool::new(registry);
671
672 let mut input = HashMap::new();
673 input.insert(
674 "file_path".to_string(),
675 serde_json::Value::String("/tmp/test.txt".to_string()),
676 );
677
678 let context = ToolContext {
679 session_id: 1,
680 tool_use_id: "test".to_string(),
681 turn_id: None,
682 permissions_pre_approved: false,
683 };
684
685 let result = tool.execute(context, input).await;
686 assert!(result.is_err());
687 assert!(result.unwrap_err().contains("Missing required 'content'"));
688 }
689
690 #[test]
691 fn test_compact_summary() {
692 let (registry, _event_rx) = create_test_registry();
693 let tool = WriteFileTool::new(registry);
694
695 let mut input = HashMap::new();
696 input.insert(
697 "file_path".to_string(),
698 serde_json::Value::String("/path/to/file.rs".to_string()),
699 );
700 input.insert(
701 "content".to_string(),
702 serde_json::Value::String("some content here".to_string()),
703 );
704
705 let summary = tool.compact_summary(&input, "Successfully created...");
706 assert_eq!(summary, "[WriteFile: file.rs (17 bytes)]");
707 }
708
709 #[test]
710 fn test_build_permission_request_create() {
711 let request = WriteFileTool::build_permission_request(
712 "test-id",
713 "/path/to/new.txt",
714 100,
715 false,
716 false,
717 );
718
719 assert_eq!(request.description, "Write file: /path/to/new.txt");
720 assert_eq!(
721 request.reason,
722 Some("create file with 100 bytes of content".to_string())
723 );
724 assert_eq!(request.target, GrantTarget::path("/path/to/new.txt", false));
725 assert_eq!(request.required_level, PermissionLevel::Write);
726 }
727
728 #[test]
729 fn test_build_permission_request_overwrite() {
730 let request = WriteFileTool::build_permission_request(
731 "test-id",
732 "/path/to/existing.txt",
733 500,
734 true,
735 false,
736 );
737
738 assert_eq!(request.description, "Write file: /path/to/existing.txt");
739 assert_eq!(
740 request.reason,
741 Some("overwrite file with 500 bytes of content".to_string())
742 );
743 assert_eq!(
744 request.target,
745 GrantTarget::path("/path/to/existing.txt", false)
746 );
747 assert_eq!(request.required_level, PermissionLevel::Write);
748 }
749
750 #[test]
751 fn test_build_permission_request_with_directory_creation() {
752 let request = WriteFileTool::build_permission_request(
753 "test-id",
754 "/new/path/file.txt",
755 200,
756 false,
757 true,
758 );
759
760 assert_eq!(request.description, "Write file: /new/path/file.txt");
761 assert_eq!(
762 request.reason,
763 Some(
764 "create file with 200 bytes of content (will create parent directories)"
765 .to_string()
766 )
767 );
768 assert_eq!(
769 request.target,
770 GrantTarget::path("/new/path/file.txt", false)
771 );
772 assert_eq!(request.required_level, PermissionLevel::Write);
773 }
774}