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