1use super::truncation::{TruncationLimits, truncate_dir_listing, truncate_file_content};
19use crate::agent::ide::IdeClient;
20use crate::agent::ui::confirmation::ConfirmationResult;
21use crate::agent::ui::diff::{confirm_file_write, confirm_file_write_with_ide};
22use rig::completion::ToolDefinition;
23use rig::tool::Tool;
24use serde::{Deserialize, Serialize};
25use serde_json::json;
26use std::collections::HashSet;
27use std::fs;
28use std::path::PathBuf;
29use std::sync::Mutex;
30
31#[derive(Debug, Deserialize)]
36pub struct ReadFileArgs {
37 pub path: String,
38 pub start_line: Option<u64>,
39 pub end_line: Option<u64>,
40}
41
42#[derive(Debug, thiserror::Error)]
43#[error("Read file error: {0}")]
44pub struct ReadFileError(String);
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct ReadFileTool {
48 project_path: PathBuf,
49}
50
51impl ReadFileTool {
52 pub fn new(project_path: PathBuf) -> Self {
53 Self { project_path }
54 }
55
56 fn validate_path(&self, requested: &PathBuf) -> Result<PathBuf, ReadFileError> {
57 let canonical_project = self
58 .project_path
59 .canonicalize()
60 .map_err(|e| ReadFileError(format!("Invalid project path: {}", e)))?;
61
62 let target = if requested.is_absolute() {
63 requested.clone()
64 } else {
65 self.project_path.join(requested)
66 };
67
68 let canonical_target = target
69 .canonicalize()
70 .map_err(|e| ReadFileError(format!("File not found: {}", e)))?;
71
72 if !canonical_target.starts_with(&canonical_project) {
73 return Err(ReadFileError(
74 "Access denied: path is outside project directory".to_string(),
75 ));
76 }
77
78 Ok(canonical_target)
79 }
80}
81
82impl Tool for ReadFileTool {
83 const NAME: &'static str = "read_file";
84
85 type Error = ReadFileError;
86 type Args = ReadFileArgs;
87 type Output = String;
88
89 async fn definition(&self, _prompt: String) -> ToolDefinition {
90 ToolDefinition {
91 name: Self::NAME.to_string(),
92 description: "Read the contents of a file in the project. Use this to examine source code, configuration files, or any text file.".to_string(),
93 parameters: json!({
94 "type": "object",
95 "properties": {
96 "path": {
97 "type": "string",
98 "description": "Path to the file to read (relative to project root)"
99 },
100 "start_line": {
101 "type": "integer",
102 "description": "Optional starting line number (1-based)"
103 },
104 "end_line": {
105 "type": "integer",
106 "description": "Optional ending line number (1-based, inclusive)"
107 }
108 },
109 "required": ["path"]
110 }),
111 }
112 }
113
114 async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
115 let requested_path = PathBuf::from(&args.path);
116 let file_path = self.validate_path(&requested_path)?;
117
118 let metadata = fs::metadata(&file_path)
119 .map_err(|e| ReadFileError(format!("Cannot read file: {}", e)))?;
120
121 const MAX_SIZE: u64 = 1024 * 1024;
122 if metadata.len() > MAX_SIZE {
123 return Ok(json!({
124 "error": format!("File too large ({} bytes). Maximum size is {} bytes.", metadata.len(), MAX_SIZE)
125 }).to_string());
126 }
127
128 let content = fs::read_to_string(&file_path)
129 .map_err(|e| ReadFileError(format!("Failed to read file: {}", e)))?;
130
131 let output = if let Some(start) = args.start_line {
132 let lines: Vec<&str> = content.lines().collect();
134 let start_idx = (start as usize).saturating_sub(1);
135 let end_idx = args
136 .end_line
137 .map(|e| (e as usize).min(lines.len()))
138 .unwrap_or(lines.len());
139
140 if start_idx >= lines.len() {
141 return Ok(json!({
142 "error": format!("Start line {} exceeds file length ({})", start, lines.len())
143 })
144 .to_string());
145 }
146
147 let end_idx = end_idx.max(start_idx);
149
150 let selected: Vec<String> = lines[start_idx..end_idx]
151 .iter()
152 .enumerate()
153 .map(|(i, line)| format!("{:>4} | {}", start_idx + i + 1, line))
154 .collect();
155
156 json!({
157 "file": args.path,
158 "lines": format!("{}-{}", start, end_idx),
159 "total_lines": lines.len(),
160 "content": selected.join("\n")
161 })
162 } else {
163 let limits = TruncationLimits::default();
165 let truncated = truncate_file_content(&content, &limits);
166
167 json!({
168 "file": args.path,
169 "total_lines": truncated.total_lines,
170 "lines_returned": truncated.returned_lines,
171 "truncated": truncated.was_truncated,
172 "content": truncated.content
173 })
174 };
175
176 serde_json::to_string_pretty(&output)
177 .map_err(|e| ReadFileError(format!("Failed to serialize: {}", e)))
178 }
179}
180
181#[derive(Debug, Deserialize)]
186pub struct ListDirectoryArgs {
187 pub path: Option<String>,
188 pub recursive: Option<bool>,
189}
190
191#[derive(Debug, thiserror::Error)]
192#[error("List directory error: {0}")]
193pub struct ListDirectoryError(String);
194
195#[derive(Debug, Clone, Serialize, Deserialize)]
196pub struct ListDirectoryTool {
197 project_path: PathBuf,
198}
199
200impl ListDirectoryTool {
201 pub fn new(project_path: PathBuf) -> Self {
202 Self { project_path }
203 }
204
205 fn validate_path(&self, requested: &PathBuf) -> Result<PathBuf, ListDirectoryError> {
206 let canonical_project = self
207 .project_path
208 .canonicalize()
209 .map_err(|e| ListDirectoryError(format!("Invalid project path: {}", e)))?;
210
211 let target = if requested.is_absolute() {
212 requested.clone()
213 } else {
214 self.project_path.join(requested)
215 };
216
217 let canonical_target = target
218 .canonicalize()
219 .map_err(|e| ListDirectoryError(format!("Directory not found: {}", e)))?;
220
221 if !canonical_target.starts_with(&canonical_project) {
222 return Err(ListDirectoryError(
223 "Access denied: path is outside project directory".to_string(),
224 ));
225 }
226
227 Ok(canonical_target)
228 }
229
230 fn list_entries(
231 &self,
232 base_path: &PathBuf,
233 current_path: &PathBuf,
234 recursive: bool,
235 depth: usize,
236 max_depth: usize,
237 entries: &mut Vec<serde_json::Value>,
238 ) -> Result<(), ListDirectoryError> {
239 let skip_dirs = [
240 "node_modules",
241 ".git",
242 "target",
243 "__pycache__",
244 ".venv",
245 "venv",
246 "dist",
247 "build",
248 ];
249
250 let dir_name = current_path
251 .file_name()
252 .and_then(|n| n.to_str())
253 .unwrap_or("");
254
255 if depth > 0 && skip_dirs.contains(&dir_name) {
256 return Ok(());
257 }
258
259 let read_dir = fs::read_dir(current_path)
260 .map_err(|e| ListDirectoryError(format!("Cannot read directory: {}", e)))?;
261
262 for entry in read_dir {
263 let entry =
264 entry.map_err(|e| ListDirectoryError(format!("Error reading entry: {}", e)))?;
265 let path = entry.path();
266 let metadata = entry.metadata().ok();
267
268 let relative_path = path
269 .strip_prefix(base_path)
270 .unwrap_or(&path)
271 .to_string_lossy()
272 .to_string();
273 let is_dir = metadata.as_ref().map(|m| m.is_dir()).unwrap_or(false);
274 let size = metadata.as_ref().map(|m| m.len()).unwrap_or(0);
275
276 entries.push(json!({
277 "name": entry.file_name().to_string_lossy(),
278 "path": relative_path,
279 "type": if is_dir { "directory" } else { "file" },
280 "size": if is_dir { None::<u64> } else { Some(size) }
281 }));
282
283 if recursive && is_dir && depth < max_depth {
284 self.list_entries(base_path, &path, recursive, depth + 1, max_depth, entries)?;
285 }
286 }
287
288 Ok(())
289 }
290}
291
292impl Tool for ListDirectoryTool {
293 const NAME: &'static str = "list_directory";
294
295 type Error = ListDirectoryError;
296 type Args = ListDirectoryArgs;
297 type Output = String;
298
299 async fn definition(&self, _prompt: String) -> ToolDefinition {
300 ToolDefinition {
301 name: Self::NAME.to_string(),
302 description: "List the contents of a directory in the project. Returns file and subdirectory names with their types and sizes.".to_string(),
303 parameters: json!({
304 "type": "object",
305 "properties": {
306 "path": {
307 "type": "string",
308 "description": "Path to the directory to list (relative to project root). Use '.' for root."
309 },
310 "recursive": {
311 "type": "boolean",
312 "description": "If true, list contents recursively (max depth 3). Default is false."
313 }
314 }
315 }),
316 }
317 }
318
319 async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
320 let path_str = args.path.as_deref().unwrap_or(".");
321
322 let requested_path = if path_str.is_empty() || path_str == "." {
323 self.project_path.clone()
324 } else {
325 PathBuf::from(path_str)
326 };
327
328 let dir_path = self.validate_path(&requested_path)?;
329 let recursive = args.recursive.unwrap_or(false);
330
331 let mut entries = Vec::new();
332 self.list_entries(&dir_path, &dir_path, recursive, 0, 3, &mut entries)?;
333
334 let limits = TruncationLimits::default();
336 let truncated = truncate_dir_listing(entries, limits.max_dir_entries);
337
338 let result = if truncated.was_truncated {
339 json!({
340 "path": path_str,
341 "entries": truncated.entries,
342 "entries_returned": truncated.entries.len(),
343 "total_count": truncated.total_entries,
344 "truncated": true,
345 "note": format!("Showing first {} of {} entries. Use a more specific path to see others.", truncated.entries.len(), truncated.total_entries)
346 })
347 } else {
348 json!({
349 "path": path_str,
350 "entries": truncated.entries,
351 "total_count": truncated.total_entries
352 })
353 };
354
355 serde_json::to_string_pretty(&result)
356 .map_err(|e| ListDirectoryError(format!("Failed to serialize: {}", e)))
357 }
358}
359
360#[derive(Debug, Deserialize)]
365pub struct WriteFileArgs {
366 pub path: String,
368 pub content: String,
370 pub create_dirs: Option<bool>,
372}
373
374#[derive(Debug, thiserror::Error)]
375#[error("Write file error: {0}")]
376pub struct WriteFileError(String);
377
378#[derive(Debug)]
380pub struct AllowedFilePatterns {
381 patterns: Mutex<HashSet<String>>,
382}
383
384impl AllowedFilePatterns {
385 pub fn new() -> Self {
386 Self {
387 patterns: Mutex::new(HashSet::new()),
388 }
389 }
390
391 pub fn is_allowed(&self, filename: &str) -> bool {
393 let patterns = self.patterns.lock().unwrap();
394 patterns.contains(filename)
395 }
396
397 pub fn allow(&self, pattern: String) {
399 let mut patterns = self.patterns.lock().unwrap();
400 patterns.insert(pattern);
401 }
402}
403
404impl Default for AllowedFilePatterns {
405 fn default() -> Self {
406 Self::new()
407 }
408}
409
410#[derive(Debug, Clone)]
411pub struct WriteFileTool {
412 project_path: PathBuf,
413 require_confirmation: bool,
415 allowed_patterns: std::sync::Arc<AllowedFilePatterns>,
417 ide_client: Option<std::sync::Arc<tokio::sync::Mutex<IdeClient>>>,
419}
420
421impl WriteFileTool {
422 pub fn new(project_path: PathBuf) -> Self {
423 Self {
424 project_path,
425 require_confirmation: true,
426 allowed_patterns: std::sync::Arc::new(AllowedFilePatterns::new()),
427 ide_client: None,
428 }
429 }
430
431 pub fn with_allowed_patterns(
433 project_path: PathBuf,
434 allowed_patterns: std::sync::Arc<AllowedFilePatterns>,
435 ) -> Self {
436 Self {
437 project_path,
438 require_confirmation: true,
439 allowed_patterns,
440 ide_client: None,
441 }
442 }
443
444 pub fn with_ide_client(
446 mut self,
447 ide_client: std::sync::Arc<tokio::sync::Mutex<IdeClient>>,
448 ) -> Self {
449 self.ide_client = Some(ide_client);
450 self
451 }
452
453 pub fn without_confirmation(mut self) -> Self {
455 self.require_confirmation = false;
456 self
457 }
458
459 fn validate_path(&self, requested: &PathBuf) -> Result<PathBuf, WriteFileError> {
460 let canonical_project = self
461 .project_path
462 .canonicalize()
463 .map_err(|e| WriteFileError(format!("Invalid project path: {}", e)))?;
464
465 let target = if requested.is_absolute() {
466 requested.clone()
467 } else {
468 self.project_path.join(requested)
469 };
470
471 let parent = target
473 .parent()
474 .ok_or_else(|| WriteFileError("Invalid path: no parent directory".to_string()))?;
475
476 let is_within_project = if parent.exists() {
478 let canonical_parent = parent
479 .canonicalize()
480 .map_err(|e| WriteFileError(format!("Invalid parent path: {}", e)))?;
481 canonical_parent.starts_with(&canonical_project)
482 } else {
483 let normalized = self.project_path.join(requested);
485 !normalized
486 .components()
487 .any(|c| c == std::path::Component::ParentDir)
488 };
489
490 if !is_within_project {
491 return Err(WriteFileError(
492 "Access denied: path is outside project directory".to_string(),
493 ));
494 }
495
496 Ok(target)
497 }
498}
499
500impl Tool for WriteFileTool {
501 const NAME: &'static str = "write_file";
502
503 type Error = WriteFileError;
504 type Args = WriteFileArgs;
505 type Output = String;
506
507 async fn definition(&self, _prompt: String) -> ToolDefinition {
508 ToolDefinition {
509 name: Self::NAME.to_string(),
510 description: r#"Write content to a file in the project. Creates the file if it doesn't exist, or overwrites if it does.
511
512**IMPORTANT**: Use this tool IMMEDIATELY when the user asks you to:
513- Create ANY file (Dockerfile, .tf, .yaml, .md, .json, etc.)
514- Generate configuration files
515- Write documentation to a specific location
516- "Put content in" or "under" a directory
517- Save analysis results or findings
518- Document anything in a file
519
520**DO NOT** just describe what you would write - actually call this tool with the content.
521
522Use cases:
523- Generate Dockerfiles for applications
524- Create Terraform configuration files (.tf)
525- Write Helm chart templates and values
526- Create docker-compose.yml files
527- Generate CI/CD configuration files (.github/workflows, .gitlab-ci.yml)
528- Write Kubernetes manifests
529- Save analysis findings to markdown files
530- Create any text file the user requests
531
532The tool will create parent directories automatically if they don't exist."#.to_string(),
533 parameters: json!({
534 "type": "object",
535 "properties": {
536 "path": {
537 "type": "string",
538 "description": "Path to the file to write (relative to project root). Example: 'Dockerfile', 'terraform/main.tf', 'helm/values.yaml'"
539 },
540 "content": {
541 "type": "string",
542 "description": "The complete content to write to the file"
543 },
544 "create_dirs": {
545 "type": "boolean",
546 "description": "If true (default), create parent directories if they don't exist"
547 }
548 },
549 "required": ["path", "content"]
550 }),
551 }
552 }
553
554 async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
555 let requested_path = PathBuf::from(&args.path);
556 let file_path = self.validate_path(&requested_path)?;
557
558 let old_content = if file_path.exists() {
560 fs::read_to_string(&file_path).ok()
561 } else {
562 None
563 };
564
565 let filename = std::path::Path::new(&args.path)
567 .file_name()
568 .map(|n| n.to_string_lossy().to_string())
569 .unwrap_or_else(|| args.path.clone());
570
571 let needs_confirmation =
573 self.require_confirmation && !self.allowed_patterns.is_allowed(&filename);
574
575 if needs_confirmation {
576 let ide_client_guard = if let Some(ref client) = self.ide_client {
578 Some(client.lock().await)
579 } else {
580 None
581 };
582 let ide_client_ref = ide_client_guard.as_deref();
583
584 let confirmation = confirm_file_write_with_ide(
586 &args.path,
587 old_content.as_deref(),
588 &args.content,
589 ide_client_ref,
590 )
591 .await;
592
593 match confirmation {
594 ConfirmationResult::Proceed => {
595 }
597 ConfirmationResult::ProceedAlways(pattern) => {
598 self.allowed_patterns.allow(pattern);
600 }
601 ConfirmationResult::Modify(feedback) => {
602 let result = json!({
604 "cancelled": true,
605 "STOP": "Do NOT create this file or any similar files. Wait for user instruction.",
606 "reason": "User requested changes",
607 "user_feedback": feedback,
608 "original_path": args.path,
609 "action_required": "Read the user_feedback and respond accordingly. Do NOT try to create alternative files."
610 });
611 return serde_json::to_string_pretty(&result)
612 .map_err(|e| WriteFileError(format!("Failed to serialize: {}", e)));
613 }
614 ConfirmationResult::Cancel => {
615 let result = json!({
617 "cancelled": true,
618 "STOP": "User has rejected this operation. Do NOT create this file or any alternative files.",
619 "reason": "User cancelled the operation",
620 "original_path": args.path,
621 "action_required": "Stop creating files. Ask the user what they want instead."
622 });
623 return serde_json::to_string_pretty(&result)
624 .map_err(|e| WriteFileError(format!("Failed to serialize: {}", e)));
625 }
626 }
627 } else {
628 use crate::agent::ui::diff::{render_diff, render_new_file};
630 use colored::Colorize;
631
632 if let Some(old) = &old_content {
633 render_diff(old, &args.content, &args.path);
634 } else {
635 render_new_file(&args.content, &args.path);
636 }
637 println!(" {} Auto-accepted", "✓".green());
638 }
639
640 let create_dirs = args.create_dirs.unwrap_or(true);
642 if create_dirs {
643 if let Some(parent) = file_path.parent() {
644 if !parent.exists() {
645 fs::create_dir_all(parent).map_err(|e| {
646 WriteFileError(format!("Failed to create directories: {}", e))
647 })?;
648 }
649 }
650 }
651
652 let file_existed = file_path.exists();
654
655 fs::write(&file_path, &args.content)
657 .map_err(|e| WriteFileError(format!("Failed to write file: {}", e)))?;
658
659 let action = if file_existed { "Updated" } else { "Created" };
660 let lines = args.content.lines().count();
661
662 let result = json!({
663 "success": true,
664 "action": action,
665 "path": args.path,
666 "lines_written": lines,
667 "bytes_written": args.content.len()
668 });
669
670 serde_json::to_string_pretty(&result)
671 .map_err(|e| WriteFileError(format!("Failed to serialize: {}", e)))
672 }
673}
674
675#[derive(Debug, Deserialize)]
680pub struct FileToWrite {
681 pub path: String,
683 pub content: String,
685}
686
687#[derive(Debug, Deserialize)]
688pub struct WriteFilesArgs {
689 pub files: Vec<FileToWrite>,
691 pub create_dirs: Option<bool>,
693}
694
695#[derive(Debug, thiserror::Error)]
696#[error("Write files error: {0}")]
697pub struct WriteFilesError(String);
698
699#[derive(Debug, Clone)]
700pub struct WriteFilesTool {
701 project_path: PathBuf,
702 require_confirmation: bool,
704 allowed_patterns: std::sync::Arc<AllowedFilePatterns>,
706 ide_client: Option<std::sync::Arc<tokio::sync::Mutex<IdeClient>>>,
708}
709
710impl WriteFilesTool {
711 pub fn new(project_path: PathBuf) -> Self {
712 Self {
713 project_path,
714 require_confirmation: true,
715 allowed_patterns: std::sync::Arc::new(AllowedFilePatterns::new()),
716 ide_client: None,
717 }
718 }
719
720 pub fn with_allowed_patterns(
722 project_path: PathBuf,
723 allowed_patterns: std::sync::Arc<AllowedFilePatterns>,
724 ) -> Self {
725 Self {
726 project_path,
727 require_confirmation: true,
728 allowed_patterns,
729 ide_client: None,
730 }
731 }
732
733 pub fn without_confirmation(mut self) -> Self {
735 self.require_confirmation = false;
736 self
737 }
738
739 pub fn with_ide_client(
741 mut self,
742 ide_client: std::sync::Arc<tokio::sync::Mutex<IdeClient>>,
743 ) -> Self {
744 self.ide_client = Some(ide_client);
745 self
746 }
747
748 fn validate_path(&self, requested: &PathBuf) -> Result<PathBuf, WriteFilesError> {
749 let canonical_project = self
750 .project_path
751 .canonicalize()
752 .map_err(|e| WriteFilesError(format!("Invalid project path: {}", e)))?;
753
754 let target = if requested.is_absolute() {
755 requested.clone()
756 } else {
757 self.project_path.join(requested)
758 };
759
760 let parent = target
761 .parent()
762 .ok_or_else(|| WriteFilesError("Invalid path: no parent directory".to_string()))?;
763
764 let is_within_project = if parent.exists() {
765 let canonical_parent = parent
766 .canonicalize()
767 .map_err(|e| WriteFilesError(format!("Invalid parent path: {}", e)))?;
768 canonical_parent.starts_with(&canonical_project)
769 } else {
770 let normalized = self.project_path.join(requested);
771 !normalized
772 .components()
773 .any(|c| c == std::path::Component::ParentDir)
774 };
775
776 if !is_within_project {
777 return Err(WriteFilesError(
778 "Access denied: path is outside project directory".to_string(),
779 ));
780 }
781
782 Ok(target)
783 }
784}
785
786impl Tool for WriteFilesTool {
787 const NAME: &'static str = "write_files";
788
789 type Error = WriteFilesError;
790 type Args = WriteFilesArgs;
791 type Output = String;
792
793 async fn definition(&self, _prompt: String) -> ToolDefinition {
794 ToolDefinition {
795 name: Self::NAME.to_string(),
796 description: r#"Write multiple files at once. Ideal for creating complete infrastructure configurations.
797
798**IMPORTANT**: Use this tool when you need to create multiple related files together.
799
800**USE THIS TOOL** (not just describe files) when the user asks for:
801- Complete Terraform modules (main.tf, variables.tf, outputs.tf, providers.tf)
802- Full Helm charts (Chart.yaml, values.yaml, templates/*.yaml)
803- Kubernetes manifests (deployment.yaml, service.yaml, configmap.yaml)
804- Multi-file docker-compose setups
805- Multiple documentation files in a directory
806- Any set of related files
807
808**DO NOT** just describe the files - actually call this tool to create them.
809
810All files are written atomically. Parent directories are created automatically."#.to_string(),
811 parameters: json!({
812 "type": "object",
813 "properties": {
814 "files": {
815 "type": "array",
816 "description": "List of files to write",
817 "items": {
818 "type": "object",
819 "properties": {
820 "path": {
821 "type": "string",
822 "description": "Path to the file (relative to project root)"
823 },
824 "content": {
825 "type": "string",
826 "description": "Content to write to the file"
827 }
828 },
829 "required": ["path", "content"]
830 }
831 },
832 "create_dirs": {
833 "type": "boolean",
834 "description": "If true (default), create parent directories if they don't exist"
835 }
836 },
837 "required": ["files"]
838 }),
839 }
840 }
841
842 async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
843 let create_dirs = args.create_dirs.unwrap_or(true);
844 let mut results = Vec::new();
845 let mut total_bytes = 0usize;
846 let mut total_lines = 0usize;
847
848 for file in &args.files {
849 let requested_path = PathBuf::from(&file.path);
850 let file_path = self.validate_path(&requested_path)?;
851
852 let old_content = if file_path.exists() {
854 fs::read_to_string(&file_path).ok()
855 } else {
856 None
857 };
858
859 let filename = std::path::Path::new(&file.path)
861 .file_name()
862 .map(|n| n.to_string_lossy().to_string())
863 .unwrap_or_else(|| file.path.clone());
864
865 let needs_confirmation =
867 self.require_confirmation && !self.allowed_patterns.is_allowed(&filename);
868
869 if needs_confirmation {
870 let confirmation = if let Some(ref client) = self.ide_client {
872 let guard = client.lock().await;
873 if guard.is_connected() {
874 confirm_file_write_with_ide(
875 &file.path,
876 old_content.as_deref(),
877 &file.content,
878 Some(&*guard),
879 )
880 .await
881 } else {
882 drop(guard);
883 confirm_file_write(&file.path, old_content.as_deref(), &file.content)
884 }
885 } else {
886 confirm_file_write(&file.path, old_content.as_deref(), &file.content)
887 };
888
889 match confirmation {
890 ConfirmationResult::Proceed => {
891 }
893 ConfirmationResult::ProceedAlways(pattern) => {
894 self.allowed_patterns.allow(pattern);
895 }
896 ConfirmationResult::Modify(feedback) => {
897 let result = json!({
899 "cancelled": true,
900 "STOP": "User provided feedback. Stop creating all remaining files in this batch.",
901 "reason": "User requested changes",
902 "user_feedback": feedback,
903 "skipped_file": file.path,
904 "files_written_before_cancel": results.len(),
905 "action_required": "Read the user_feedback. Do NOT continue with remaining files."
906 });
907 return serde_json::to_string_pretty(&result)
908 .map_err(|e| WriteFilesError(format!("Failed to serialize: {}", e)));
909 }
910 ConfirmationResult::Cancel => {
911 let result = json!({
913 "cancelled": true,
914 "STOP": "User cancelled. Stop creating all files immediately.",
915 "reason": "User cancelled the operation",
916 "skipped_file": file.path,
917 "files_written_before_cancel": results.len(),
918 "action_required": "Stop all file creation. Ask the user what they want instead."
919 });
920 return serde_json::to_string_pretty(&result)
921 .map_err(|e| WriteFilesError(format!("Failed to serialize: {}", e)));
922 }
923 }
924 } else {
925 use crate::agent::ui::diff::{render_diff, render_new_file};
927 use colored::Colorize;
928
929 if let Some(old) = &old_content {
930 render_diff(old, &file.content, &file.path);
931 } else {
932 render_new_file(&file.content, &file.path);
933 }
934 println!(" {} Auto-accepted", "✓".green());
935 }
936
937 if create_dirs {
939 if let Some(parent) = file_path.parent() {
940 if !parent.exists() {
941 fs::create_dir_all(parent).map_err(|e| {
942 WriteFilesError(format!(
943 "Failed to create directories for {}: {}",
944 file.path, e
945 ))
946 })?;
947 }
948 }
949 }
950
951 let file_existed = file_path.exists();
952
953 fs::write(&file_path, &file.content)
954 .map_err(|e| WriteFilesError(format!("Failed to write {}: {}", file.path, e)))?;
955
956 let lines = file.content.lines().count();
957 total_bytes += file.content.len();
958 total_lines += lines;
959
960 results.push(json!({
961 "path": file.path,
962 "action": if file_existed { "updated" } else { "created" },
963 "lines": lines,
964 "bytes": file.content.len()
965 }));
966 }
967
968 let result = json!({
971 "success": true,
972 "files_written": results.len(),
973 "total_lines": total_lines,
974 "total_bytes": total_bytes,
975 "files": results
976 });
977
978 serde_json::to_string_pretty(&result)
979 .map_err(|e| WriteFilesError(format!("Failed to serialize: {}", e)))
980 }
981}